Muhammad Mehdi commited on
Commit
4cd9a68
·
2 Parent(s): 9b5cc9dfa6e4a3

Merge pull request #6 from FireBird-Technologies/feat/slide-deck

Browse files
Files changed (50) hide show
  1. backend/app/db.py +7 -2
  2. backend/app/infographics/image.py +19 -6
  3. backend/app/llm/signatures.py +183 -7
  4. backend/app/pipeline/ingest.py +3 -0
  5. backend/app/pipeline/media.py +61 -21
  6. backend/app/pipeline/slides.py +374 -0
  7. backend/app/routers/artifacts.py +87 -5
  8. backend/app/routers/launchpad.py +82 -20
  9. backend/app/services/artifacts.py +8 -2
  10. backend/app/services/launchpad.py +3 -1
  11. backend/app/services/social/media.py +32 -3
  12. backend/app/services/social/x.py +1 -1
  13. backend/app/slides/__init__.py +3 -0
  14. backend/app/slides/build.py +151 -0
  15. backend/app/slides/content.py +204 -0
  16. backend/app/slides/decor.py +185 -0
  17. backend/app/slides/export.py +356 -0
  18. backend/app/slides/layout.py +734 -0
  19. backend/app/slides/metrics.py +39 -0
  20. backend/app/slides/render.py +184 -0
  21. backend/app/slides/themes.py +47 -0
  22. backend/app/worker.py +16 -3
  23. backend/requirements.txt +1 -0
  24. backend/scripts/render_slide_thumbnails.py +44 -0
  25. backend/tests/test_buildout.py +59 -5
  26. backend/tests/test_slides.py +590 -0
  27. backend/tests/test_social_posting.py +170 -2
  28. frontend/src/App.tsx +6 -6
  29. frontend/src/api/client.ts +6 -1
  30. frontend/src/api/endpoints.ts +36 -2
  31. frontend/src/api/types.ts +7 -2
  32. frontend/src/components/AppShell.tsx +2 -0
  33. frontend/src/components/ArtifactCard.tsx +1 -1
  34. frontend/src/components/ArtifactRow.tsx +23 -3
  35. frontend/src/components/AudioOverview.tsx +318 -0
  36. frontend/src/components/PaneResizer.tsx +188 -0
  37. frontend/src/components/ScheduleModal.tsx +6 -5
  38. frontend/src/components/SlideDeck.tsx +1054 -0
  39. frontend/src/components/icons/Icons.tsx +56 -0
  40. frontend/src/components/launchpad/Attachment.tsx +89 -14
  41. frontend/src/components/launchpad/PostButton.tsx +3 -2
  42. frontend/src/components/slideDeckEdit.ts +173 -0
  43. frontend/src/components/ui.tsx +45 -3
  44. frontend/src/pages/Archive.tsx +40 -13
  45. frontend/src/pages/Launchpad.tsx +72 -9
  46. frontend/src/pages/NotebookView.tsx +62 -97
  47. frontend/src/pages/VideoCreate.tsx +1 -1
  48. frontend/src/pages/VoiceProfile.tsx +40 -187
  49. frontend/src/pages/Voices.tsx +12 -0
  50. frontend/src/styles/pages.css +1158 -100
backend/app/db.py CHANGED
@@ -11,8 +11,13 @@ class Base(DeclarativeBase):
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
- _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else {}
15
- engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args)
 
 
 
 
 
16
 
17
  if _sqlite:
18
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
 
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
+ # Postgres: a connection that dies silently (laptop sleep, network change, Neon dropping it) otherwise leaves a query
15
+ # waiting on the socket forever, and the worker loop with it. Keepalives notice a dead peer within about a minute
16
+ # and the query fails instead; connect_timeout does the same for a connect that never completes.
17
+ _pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10,
18
+ "keepalives_count": 3}
19
+ _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args
20
+ engine = create_engine(settings.database_url, pool_pre_ping=True, pool_recycle=300, connect_args=_connect_args)
21
 
22
  if _sqlite:
23
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
backend/app/infographics/image.py CHANGED
@@ -1,6 +1,7 @@
1
  """An infographic's PNG: its stored HTML page drawn by a headless Chrome at the page's own size. No Remotion, no
2
  browser library: one `chrome --headless --screenshot` run per download (the result is kept for the next one)."""
3
 
 
4
  import glob
5
  import hashlib
6
  import os
@@ -24,17 +25,29 @@ class ImageUnavailable(Exception):
24
  """No Chrome to draw with, or it failed."""
25
 
26
 
 
 
 
 
 
 
 
 
 
27
  def chrome() -> str | None:
28
- """CHROME, or a Chrome / Chromium found on the machine."""
29
  if env := os.environ.get("CHROME"):
30
  return env if Path(env).exists() else None
31
- for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome"):
32
- if found := shutil.which(name):
33
- return found
34
- patterns = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
 
 
35
  os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/"
36
  "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")]
37
- return next((p for pat in patterns for p in glob.glob(pat)), None)
 
38
 
39
 
40
  def render_png(html: str, layout: str = "portrait") -> bytes:
 
1
  """An infographic's PNG: its stored HTML page drawn by a headless Chrome at the page's own size. No Remotion, no
2
  browser library: one `chrome --headless --screenshot` run per download (the result is kept for the next one)."""
3
 
4
+ import functools
5
  import glob
6
  import hashlib
7
  import os
 
25
  """No Chrome to draw with, or it failed."""
26
 
27
 
28
+ @functools.cache
29
+ def _runs(path: str) -> bool:
30
+ """Whether the binary actually starts (a package manager's wrapper can outlive the browser it points at)."""
31
+ try:
32
+ return subprocess.run([path, "--version"], capture_output=True, timeout=20).returncode == 0
33
+ except (subprocess.SubprocessError, OSError):
34
+ return False
35
+
36
+
37
  def chrome() -> str | None:
38
+ """CHROME, or a working Chrome / Chromium found on the machine."""
39
  if env := os.environ.get("CHROME"):
40
  return env if Path(env).exists() else None
41
+ found = [p for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome")
42
+ if (p := shutil.which(name))]
43
+ # Playwright's headless shell first: desktop Chrome on macOS can hang taking a headless screenshot.
44
+ shell = "chromium_headless_shell-*/chrome-headless-shell-*/chrome-headless-shell"
45
+ patterns = [os.path.expanduser(f"~/Library/Caches/ms-playwright/{shell}"), os.path.expanduser(f"~/.cache/ms-playwright/{shell}"),
46
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
47
  os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/"
48
  "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")]
49
+ found += [p for pat in patterns for p in glob.glob(pat)]
50
+ return next((p for p in found if _runs(p)), None)
51
 
52
 
53
  def render_png(html: str, layout: str = "portrait") -> bytes:
backend/app/llm/signatures.py CHANGED
@@ -260,14 +260,25 @@ class ExtractClaims(dspy.Signature):
260
  claims: list[Claim] = dspy.OutputField()
261
 
262
 
263
- class PodcastScript(dspy.Signature):
264
- """Write a two host conversation about the material. Hosts stay grounded in the passages and cite
265
- them. Natural, warm, curious. No em dashes."""
 
 
 
 
 
 
 
266
 
267
- passages: list[str] = dspy.InputField()
268
- format: Literal["deep_dive", "brief", "debate"] = dspy.InputField()
269
- target_minutes: int = dspy.InputField()
270
- lines: list[ScriptLine] = dspy.OutputField()
 
 
 
 
271
 
272
 
273
  class HookGenerator(dspy.Signature):
@@ -680,3 +691,168 @@ class VideoFocusAngles(dspy.Signature):
680
  desc="Topics this material is about: 'Name (in how many items): what it covers'. May be empty")
681
  avoid_titles: list[str] = dspy.InputField(desc="Titles already suggested. Do not repeat or reword them")
682
  topics: list[FocusAngle] = dspy.OutputField(desc="Exactly 3 focus topics")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  claims: list[Claim] = dspy.OutputField()
261
 
262
 
263
+ class WriteAudioScript(dspy.Signature):
264
+ """Write the script of an audio overview of the material, to be read aloud. Use only what the material says:
265
+ never add facts, numbers or names from outside it, and cite the lines each turn draws on. Follow the focus when
266
+ one is given: it says what to cover, what to emphasize and what to skip, as far as the material supports it. Write
267
+ in the style given. With two hosts, write a real back and forth: host_a and host_b take turns, every turn is one
268
+ to three sentences, and they react to each other (a question, a surprise, an example, a disagreement in a
269
+ debate). With one host, write a single narrator speaking straight to the listener, every turn host_a. Make it
270
+ sound spoken: plain sentences, no markdown, no lists, no stage directions, no sound effects, no reading out of
271
+ file names or line numbers. Aim for the target number of words. Write in the language requested. Never use em
272
+ dashes."""
273
 
274
+ title: str = dspy.InputField(desc="What the material is from")
275
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
276
+ style: str = dspy.InputField(desc="The style's name and what it should sound like")
277
+ hosts: int = dspy.InputField(desc="1 (a single narrator) or 2 (a conversation)")
278
+ target_words: int = dspy.InputField(desc="About how many words the whole script should be")
279
+ focus: str = dspy.InputField(desc="What the person wants the episode to focus on, or (none)")
280
+ language: str = dspy.InputField(desc="The language to write in")
281
+ turns: list[ScriptLine] = dspy.OutputField()
282
 
283
 
284
  class HookGenerator(dspy.Signature):
 
691
  desc="Topics this material is about: 'Name (in how many items): what it covers'. May be empty")
692
  avoid_titles: list[str] = dspy.InputField(desc="Titles already suggested. Do not repeat or reword them")
693
  topics: list[FocusAngle] = dspy.OutputField(desc="Exactly 3 focus topics")
694
+
695
+
696
+ # Slide decks: an outline, each slide written from the lines it cites, every claim checked against those lines,
697
+ # then a conclusion written from the slides themselves. The model writes words only; code lays them out.
698
+
699
+
700
+ class DeckSlidePlan(BaseModel):
701
+ layout: Literal["section", "points", "two_column", "stat", "quote"] = Field(
702
+ description="points: an idea explained in 2 to 5 parts; two_column: a comparison of two sides; stat: one "
703
+ "number the material states; quote: one line the material says word for word; section: a short "
704
+ "turning point between parts")
705
+ heading: str = Field(description="The slide's headline: a claim or a clear topic, at most 9 words")
706
+ purpose: str = Field(description="What this slide must get across, in one sentence")
707
+ sources: list[SourceRef] = Field(default_factory=list, description="The lines this slide draws on")
708
+
709
+
710
+ class DeckOutline(BaseModel):
711
+ title: str = Field(description="The deck's title, at most 8 words")
712
+ subtitle: str = Field(description="One line that makes the audience want to listen, at most 18 words")
713
+ opening_kicker: str = Field(description="1 to 3 words above the title naming the field or the occasion")
714
+ agenda_heading: str = Field(description="Heading for the agenda slide, e.g. What we will cover")
715
+ slides: list[DeckSlidePlan]
716
+
717
+
718
+ class OutlineDeck(dspy.Signature):
719
+ """Plan a slide deck from the material: the story it tells, in order, as content slides (an opening slide, an agenda
720
+ and a conclusion are added separately, so do not plan them). Use only what the material says. Each slide makes
721
+ one point and builds on the one before, so the deck reads as an argument from start to finish, not a list of
722
+ facts, and no two slides cover the same facts. When the posts are about unrelated subjects, give each its own
723
+ part of the deck and never connect them: a fact from one post is never said of another post's subject, in a
724
+ slide, the title or the subtitle (and the deck does not remark on keeping them apart). Vary the layouts: never
725
+ put two slides of the same layout in a row when another layout fits, use a stat slide only for a number the
726
+ material states, a quote slide only for a line the material says word for word, and a two_column slide only when
727
+ the material compares two things. The request, when given, decides the deck's focus, audience, angle, structure
728
+ and what to include or leave out: plan the deck it describes, as far as the material supports it, and leave out
729
+ what the material does not cover rather than invent it. The number of slides and the format are fixed by
730
+ slide_count and deck_format, whatever the request says. Cite the lines each slide draws on. Follow the request
731
+ when one is given. Write the headings in the language requested. Never use em dashes."""
732
+
733
+ title: str = dspy.InputField(desc="What the material is from")
734
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
735
+ request: str = dspy.InputField(desc="What the person wants the deck to be about or how it should feel, or (none)")
736
+ deck_format: str = dspy.InputField(desc="detailed (read on its own) or presenter (shown while someone speaks)")
737
+ slide_count: str = dspy.InputField(desc="How many content slides to plan, e.g. 3 to 5")
738
+ language: str = dspy.InputField(desc="The language to write in")
739
+ outline: DeckOutline = dspy.OutputField()
740
+
741
+
742
+ class SlidePoint(BaseModel):
743
+ term: str = Field(default="", description="Detailed: the idea's name in 1 to 4 words. Presenter: leave empty")
744
+ text: str = Field(description="Detailed: one or two sentences that explain the idea, at most 30 words. "
745
+ "Presenter: a highlight of at most 8 words")
746
+
747
+
748
+ class SlideColumn(BaseModel):
749
+ label: str = Field(description="The side's name, 1 to 3 words")
750
+ items: list[str] = Field(description="2 to 4 short items, at most 14 words each (presenter: 8)")
751
+
752
+
753
+ class SlideStat(BaseModel):
754
+ value: str = Field(description="The number as the material gives it, with its sign or unit, e.g. #1, 3x, 42%, $9")
755
+ label: str = Field(description="What the number measures, at most 16 words")
756
+
757
+
758
+ class SlideQuote(BaseModel):
759
+ text: str = Field(description="Copied word for word from the source lines, at most 40 words")
760
+ by: str = Field(default="", description="Who said it, when the material says, else empty")
761
+
762
+
763
+ class SlideOut(BaseModel):
764
+ kicker: str = Field(description="1 to 3 words above the heading: the slide's topic")
765
+ heading: str = Field(description="The headline, at most 9 words (presenter: 7)")
766
+ lead: str = Field(default="", description="Detailed: 1 or 2 sentences framing the slide, at most 35 words. "
767
+ "Presenter: one short line or empty")
768
+ points: list[SlidePoint] = Field(default_factory=list, description="For points slides: detailed 3 to 5, presenter 2 to 4")
769
+ left: SlideColumn | None = Field(default=None, description="For two_column slides: the first side")
770
+ right: SlideColumn | None = Field(default=None, description="For two_column slides: the second side")
771
+ stat: SlideStat | None = Field(default=None, description="For stat slides")
772
+ quote: SlideQuote | None = Field(default=None, description="For quote slides")
773
+ notes: str = Field(description="Speaker notes: what to say over this slide, 60 to 150 words, explaining it fully")
774
+ sources: list[SourceRef] = Field(default_factory=list, description="The lines this slide's words come from")
775
+
776
+
777
+ class WriteSlide(dspy.Signature):
778
+ """Write one slide of a deck, using only the source lines given: never add facts, numbers or names from outside them.
779
+ The slide must make the point its plan describes and fit the deck's flow. Detailed format: the slide is read on
780
+ its own, so explain each idea: a clear heading, a lead that frames it, and points that each name an idea and
781
+ explain it in a full sentence (why it matters or how it works, not just what it is). Presenter format: the slide
782
+ supports a speaker, so keep it sparse: a short heading and a few highlights of a handful of words, and put the
783
+ full explanation in the speaker notes. Say only what this slide is for: the other slides in the outline cover
784
+ the rest, so do not repeat them. Apply the request's audience, tone and style to this slide, and anything it
785
+ asks of this part of the deck, as far as the source lines support it. Fill only the fields the layout uses. Keep
786
+ every line plain text: no markdown, no bullets characters, no numbering. Write in the language requested. Never
787
+ use em dashes."""
788
+
789
+ deck_title: str = dspy.InputField()
790
+ outline: list[str] = dspy.InputField(desc="Every slide in the deck, in order, so this one fits the flow")
791
+ position: str = dspy.InputField(desc="Which slide this is, e.g. Slide 3 of 8")
792
+ layout: str = dspy.InputField(desc="points, two_column, stat, quote or section")
793
+ heading: str = dspy.InputField(desc="The planned headline (improve it if the sources suggest a sharper one)")
794
+ purpose: str = dspy.InputField(desc="What the slide must get across")
795
+ deck_format: str = dspy.InputField(desc="detailed or presenter")
796
+ request: str = dspy.InputField(desc="What the person asked of the deck, or (none)")
797
+ source_lines: list[str] = dspy.InputField(desc="The lines to write from, with path and line numbers")
798
+ language: str = dspy.InputField(desc="The language to write in")
799
+ slide: SlideOut = dspy.OutputField()
800
+
801
+
802
+ class ClaimCheck(BaseModel):
803
+ n: int = Field(description="The claim's number")
804
+ supported: bool = Field(description="True when the source lines say this (paraphrase is fine)")
805
+ fix: str = Field(default="", description="When not supported: the claim rewritten so the sources support it, in "
806
+ "the same style and length, or empty when they say nothing close")
807
+
808
+
809
+ class CheckSlide(dspy.Signature):
810
+ """Check each numbered claim from a slide against the source lines. A claim is supported when the lines say it, in
811
+ other words or in fewer words. It is not supported when it adds a fact, number, name, cause or certainty the
812
+ lines do not give, or changes what they mean. For each unsupported claim, give a fixed version the lines do
813
+ support, or leave the fix empty. A fix is only the sentence itself, in the slide's own voice: no name or label
814
+ before it, and never a mention of the sources, the post, the resume or the material. Keep the claim's language.
815
+ Never use em dashes."""
816
+
817
+ claims: list[str] = dspy.InputField(desc="Numbered claims from one slide (a name before a colon is context only: "
818
+ "check and fix only what follows it)")
819
+ source_lines: list[str] = dspy.InputField(desc="What the slide was written from")
820
+ checks: list[ClaimCheck] = dspy.OutputField(desc="One check per claim")
821
+
822
+
823
+ class DeckClosing(BaseModel):
824
+ kicker: str = Field(description="1 to 2 words above the heading, e.g. Conclusion")
825
+ heading: str = Field(description="Heading for the closing slide, e.g. Key takeaways")
826
+ takeaways: list[str] = Field(description="Exactly 3 takeaways, each a full sentence of at most 20 words "
827
+ "(presenter: 10)")
828
+ closing: str = Field(description="The one idea to remember, at most 12 words: a strong last line, not a thank you")
829
+ notes: str = Field(description="Speaker notes for the close, 50 to 120 words")
830
+
831
+
832
+ class WriteClosing(dspy.Signature):
833
+ """Write the closing slide of a deck from its slides: three takeaways that sum up what the deck showed, in the order it
834
+ showed them, and one closing line that leaves the audience with the single idea to remember. Follow the request
835
+ when it asks something of the ending (for example next steps, a call to action or who it is for), as far as the
836
+ slides support it. Use only what the slides say. Write in the language requested. Never use em dashes."""
837
+
838
+ deck_title: str = dspy.InputField()
839
+ slides: list[str] = dspy.InputField(desc="Each content slide's heading and main text, in order")
840
+ deck_format: str = dspy.InputField(desc="detailed or presenter")
841
+ request: str = dspy.InputField(desc="What the person asked of the deck, or (none)")
842
+ language: str = dspy.InputField(desc="The language to write in")
843
+ closing: DeckClosing = dspy.OutputField()
844
+
845
+
846
+ class RequestAsk(BaseModel):
847
+ ask: str = Field(description="One concrete thing the person asked of the deck, in a few words")
848
+ covered: bool = Field(description="True when the planned slides already do it")
849
+
850
+
851
+ class CheckRequest(dspy.Signature):
852
+ """Read what the person asked of a slide deck and list each concrete ask in it: a topic or part to cover, an
853
+ audience or angle, a structure, something to include, to leave out, or to end with. For each, say whether the
854
+ planned slides already do it. Ignore asks about the number of slides or the visual style. Never use em dashes."""
855
+
856
+ request: str = dspy.InputField(desc="What the person asked of the deck")
857
+ slides: list[str] = dspy.InputField(desc="The planned slides: heading, then what each must get across")
858
+ asks: list[RequestAsk] = dspy.OutputField()
backend/app/pipeline/ingest.py CHANGED
@@ -551,6 +551,9 @@ def store_entries(
551
  if not sections:
552
  skipped += 1
553
  continue
 
 
 
554
  if not doc:
555
  doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
556
  db.add(doc)
 
551
  if not sections:
552
  skipped += 1
553
  continue
554
+ # Postgres text cannot hold NUL bytes, and some PDFs (icon glyphs) extract with them.
555
+ sections = [(h and h.replace("\x00", ""), t.replace("\x00", "")) for h, t in sections]
556
+ entry.title = entry.title.replace("\x00", "")
557
  if not doc:
558
  doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
559
  db.add(doc)
backend/app/pipeline/media.py CHANGED
@@ -1,5 +1,6 @@
1
- """Audio overviews (two hosts, ElevenLabs), audiograms and stills (Remotion renderer)."""
2
 
 
3
  import uuid
4
 
5
  from sqlalchemy import select
@@ -8,10 +9,12 @@ from sqlalchemy.orm import Session
8
  from app.config import settings
9
  from app.corpus import Corpus
10
  from app.llm import run
11
- from app.llm.signatures import PickQuotes, PodcastScript
12
  from app.models import Artifact, Document, Job, Source, User, VoiceProfile, Workspace
13
  from app.pipeline.generate import NothingToDo
 
14
  from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify_refs
 
15
  from app.services import tts
16
  from app.services.jobs import record_usage, update_job
17
  from app.services.renderer import PermanentJobError, brand_props, request_render
@@ -48,28 +51,61 @@ def _require_tts() -> None:
48
  # Audio overview
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
52
  _require_tts()
53
- params = job.params
54
- docs = artifact_docs(db, artifact)
55
- if not docs:
56
- raise NothingToDo("Add posts before making an audio overview.")
57
- corpus = Corpus(artifact.workspace_id)
58
- update_job(db, job, progress=0.05, message=f"Reading {len(docs)} posts")
59
- passages = passages_for(corpus, docs, budget_chars=90_000)
60
- update_job(db, job, progress=0.15, message="Writing the conversation")
61
- out = run.predict(PodcastScript, db=db, workspace_id=artifact.workspace_id, job=job, passages=passages,
62
- format=params.get("format", "deep_dive"), target_minutes=int(params.get("minutes", 6)))
63
- lines = [ln for ln in (out.get("lines") or []) if (ln.get("text") or "").strip()]
 
 
64
  if not lines:
65
  raise RuntimeError("The script came back empty")
66
- tools = tools_for(corpus, docs)
67
  default_a, default_b = host_voices(db, artifact.workspace_id)
68
  voice_a, voice_b = params.get("host_a") or default_a, params.get("host_b") or default_b # picked when made
69
  delivery = host_settings(db, artifact.workspace_id)
70
  script = [
71
- tts.Line(text=ln["text"], voice_id=voice_b if ln.get("speaker") == "host_b" else voice_a,
72
- settings=delivery["host_b" if ln.get("speaker") == "host_b" else "host_a"])
73
  for ln in lines
74
  ]
75
  update_job(db, job, progress=0.2, message=f"Recording {len(script)} lines")
@@ -80,14 +116,14 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
80
  message=f"Recorded {done} of {len(script)} lines")
81
 
82
  clips = tts.synthesize_many(artifact.workspace_id, script, on_done=progress)
 
83
  audio = bytearray()
84
  segments = []
85
  t = 0.0
86
  for line, clip in zip(lines, clips, strict=True):
87
  seconds = tts.mp3_seconds(clip)
88
- segments.append({"speaker": line.get("speaker", "host_a"), "text": line["text"],
89
- "start": round(t, 2), "end": round(t + seconds, 2),
90
- "sources": verify_refs(tools, line.get("sources") or [])})
91
  audio += clip
92
  t += seconds
93
  key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
@@ -96,10 +132,14 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
96
  artifact.status = "ready"
97
  artifact.content_json = {
98
  **(artifact.content_json or {}),
99
- "format": params.get("format", "deep_dive"),
 
 
 
 
100
  "duration_s": round(t, 1),
101
  "segments": segments,
102
- "voices": {"host_a": voice_a, "host_b": voice_b},
103
  }
104
  db.commit()
105
  record_usage(db, workspace_id=artifact.workspace_id, kind="tts", provider="elevenlabs",
 
1
+ """Audio overviews (one or two hosts, ElevenLabs), audiograms and stills (Remotion renderer)."""
2
 
3
+ import re
4
  import uuid
5
 
6
  from sqlalchemy import select
 
9
  from app.config import settings
10
  from app.corpus import Corpus
11
  from app.llm import run
12
+ from app.llm.signatures import PickQuotes, WriteAudioScript
13
  from app.models import Artifact, Document, Job, Source, User, VoiceProfile, Workspace
14
  from app.pipeline.generate import NothingToDo
15
+ from app.pipeline.material import load_material
16
  from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify_refs
17
+ from app.pipeline.report import SAME_LANGUAGE
18
  from app.services import tts
19
  from app.services.jobs import record_usage, update_job
20
  from app.services.renderer import PermanentJobError, brand_props, request_render
 
51
  # Audio overview
52
 
53
 
54
+ STYLES = {
55
+ "deep_dive": "Deep Dive: a lively, curious conversation that unpacks the ideas and connects them to each other",
56
+ "brief": "Brief: a short, bite sized overview that gets the core ideas across quickly",
57
+ "critique": "Critique: an expert review of the material, with honest, constructive feedback on how to improve it",
58
+ "debate": "Debate: two hosts take different sides on the material's main questions and argue them respectfully",
59
+ }
60
+ WORDS_PER_MINUTE = 150
61
+ _MARKUP = re.compile(r"(\*\*|__|`|#{1,6}\s|^\s*[-*•]\s+|\[\d+\])", re.M)
62
+
63
+
64
+ def script_turns(turns: list[dict], hosts: int) -> list[dict]:
65
+ """The model's turns ready to voice: empty ones dropped and markup removed. With two hosts, consecutive turns by
66
+ one host are joined and the speakers then strictly alternate, host A first, so the conversation always goes A, B,
67
+ A, B. With one host, every turn is host A's."""
68
+ clean = []
69
+ for t in turns or []:
70
+ text = re.sub(r"\s+", " ", _MARKUP.sub("", str((t or {}).get("text") or ""))).replace("\u2014", ", ").strip()
71
+ if text:
72
+ clean.append({"speaker": (t or {}).get("speaker") or "host_a", "text": text,
73
+ "sources": list((t or {}).get("sources") or [])})
74
+ if hosts == 1:
75
+ return [{**t, "speaker": "host_a"} for t in clean]
76
+ merged: list[dict] = []
77
+ for t in clean:
78
+ if merged and merged[-1]["speaker"] == t["speaker"]:
79
+ merged[-1]["text"] += " " + t["text"]
80
+ merged[-1]["sources"] += t["sources"]
81
+ else:
82
+ merged.append(dict(t))
83
+ return [{**t, "speaker": "host_a" if i % 2 == 0 else "host_b"} for i, t in enumerate(merged)]
84
+
85
+
86
  def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
87
  _require_tts()
88
+ params = dict(job.params)
89
+ if artifact.document_id and not params.get("document_ids") and not params.get("chat_ids"):
90
+ params["document_ids"] = [str(artifact.document_id)] # made from one post
91
+ fmt = params.get("format") if params.get("format") in STYLES else "deep_dive"
92
+ hosts = 1 if params.get("hosts") == 1 else 2 # older jobs had no choice: two hosts
93
+ minutes = int(params.get("minutes", 6))
94
+ focus = (params.get("instructions") or "").strip()
95
+ material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="audio overview")
96
+ update_job(db, job, progress=0.15, message="Writing the conversation" if hosts == 2 else "Writing the narration")
97
+ out = run.predict(WriteAudioScript, db=db, workspace_id=artifact.workspace_id, job=job, title=material.title,
98
+ material=material.text, style=STYLES[fmt], hosts=hosts, target_words=minutes * WORDS_PER_MINUTE,
99
+ focus=focus or "(none)", language=(params.get("language") or "").strip() or SAME_LANGUAGE)
100
+ lines = script_turns(out.get("turns") or [], hosts)
101
  if not lines:
102
  raise RuntimeError("The script came back empty")
 
103
  default_a, default_b = host_voices(db, artifact.workspace_id)
104
  voice_a, voice_b = params.get("host_a") or default_a, params.get("host_b") or default_b # picked when made
105
  delivery = host_settings(db, artifact.workspace_id)
106
  script = [
107
+ tts.Line(text=ln["text"], voice_id=voice_b if ln["speaker"] == "host_b" else voice_a,
108
+ settings=delivery[ln["speaker"]])
109
  for ln in lines
110
  ]
111
  update_job(db, job, progress=0.2, message=f"Recording {len(script)} lines")
 
116
  message=f"Recorded {done} of {len(script)} lines")
117
 
118
  clips = tts.synthesize_many(artifact.workspace_id, script, on_done=progress)
119
+ # Every clip is the same MP3 format (mp3_44100_128), so the clips join into one file in order.
120
  audio = bytearray()
121
  segments = []
122
  t = 0.0
123
  for line, clip in zip(lines, clips, strict=True):
124
  seconds = tts.mp3_seconds(clip)
125
+ segments.append({"speaker": line["speaker"], "text": line["text"],
126
+ "start": round(t, 2), "end": round(t + seconds, 2), "sources": material.refs(line["sources"])})
 
127
  audio += clip
128
  t += seconds
129
  key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
 
132
  artifact.status = "ready"
133
  artifact.content_json = {
134
  **(artifact.content_json or {}),
135
+ "format": fmt,
136
+ "hosts": hosts,
137
+ "focus": focus,
138
+ "language": params.get("language") or "",
139
+ "source": material.source,
140
  "duration_s": round(t, 1),
141
  "segments": segments,
142
+ "voices": {"host_a": voice_a, **({"host_b": voice_b} if hosts == 2 else {})},
143
  }
144
  db.commit()
145
  record_usage(db, workspace_id=artifact.workspace_id, kind="tts", provider="elevenlabs",
backend/app/pipeline/slides.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Slide decks. The model plans the deck (OutlineDeck), writes each content slide from the lines it cites
2
+ (WriteSlide, in parallel), has every claim checked against those lines (CheckSlide), and writes the conclusion from
3
+ the finished slides (WriteClosing). The opening slide and the agenda come from the outline. Code then lays the deck
4
+ out (app.slides): the model never writes markup, positions or colours, and only the deck's words are stored."""
5
+
6
+ import logging
7
+ import re
8
+ import uuid
9
+
10
+ from sqlalchemy import select
11
+ from sqlalchemy.orm import Session
12
+
13
+ from app.llm import run
14
+ from app.llm.provider import fast_lm, report_lm
15
+ from app.llm.signatures import CheckRequest, CheckSlide, OutlineDeck, WriteClosing, WriteSlide
16
+ from app.models import Artifact, Job
17
+ from app.pipeline.generate import NothingToDo
18
+ from app.pipeline.material import Material, load_material
19
+ from app.pipeline.report import SAME_LANGUAGE
20
+ from app.services.jobs import update_job
21
+ from app.slides.build import finish
22
+ from app.slides.content import FORMATS, LENGTHS, clamp_deck, clamp_slide, plain
23
+ from app.slides.layout import seed_of
24
+ from app.slides.themes import theme_id
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ CONTENT_LAYOUTS = ("section", "points", "two_column", "stat", "quote")
29
+ SOURCE_BUDGET = 7_000 # characters of cited lines one slide is written from (a PDF's lines can be whole pages)
30
+ CONTEXT_BUDGET = 9_000 # characters a slide without usable citations is written from
31
+
32
+
33
+ def content_range(length: str) -> tuple[int, int]:
34
+ """How many content slides a deck of this length has: its total minus the opening, the closing and (above the
35
+ short length) the agenda."""
36
+ lo, hi = LENGTHS[length]
37
+ fixed = 3 if has_agenda(length) else 2
38
+ return lo - fixed, hi - fixed
39
+
40
+
41
+ def has_agenda(length: str) -> bool:
42
+ return length != "short"
43
+
44
+
45
+ def _missed(db: Session, job: Job, artifact: Artifact, request: str, plans: list[dict]) -> list[str]:
46
+ """What the request asked for that the planned slides do not do (empty when nothing is missing or the check fails)."""
47
+ if not request or not plans:
48
+ return []
49
+ try:
50
+ out = run.predict(CheckRequest, db=db, workspace_id=artifact.workspace_id, job=job, lm=fast_lm(), request=request,
51
+ slides=[f"{p.get('heading')}: {p.get('purpose') or ''}" for p in plans])
52
+ except Exception:
53
+ return []
54
+ return [plain(a.get("ask")) for a in out.get("asks") or [] if isinstance(a, dict) and a.get("covered") is False
55
+ and plain(a.get("ask"))]
56
+
57
+
58
+ def _outline(db: Session, job: Job, artifact: Artifact, material: Material, fmt: str, length: str, request: str,
59
+ language: str) -> dict:
60
+ lo, hi = content_range(length)
61
+ count = f"{lo} to {hi}" + (", including 2 to 4 section slides that divide the deck into parts" if length == "long" else "")
62
+ ask = request or "(none)"
63
+ best: dict = {}
64
+ for attempt in range(2):
65
+ try:
66
+ out = run.predict(OutlineDeck, db=db, workspace_id=artifact.workspace_id, job=job, lm=report_lm(),
67
+ title=material.title, material=material.text, request=ask, deck_format=fmt,
68
+ slide_count=count, language=language)
69
+ except Exception:
70
+ if attempt:
71
+ raise
72
+ continue
73
+ outline = out.get("outline") or {}
74
+ plans = [p for p in outline.get("slides") or []
75
+ if isinstance(p, dict) and p.get("layout") in CONTENT_LAYOUTS and plain(p.get("heading"))]
76
+ outline["slides"] = plans
77
+ if len(plans) > len(best.get("slides") or []):
78
+ best = outline
79
+ if lo <= len(plans):
80
+ break
81
+ ask = f"{request or '(none)'}\n\nPlan between {lo} and {hi} content slides."
82
+ plans = best.get("slides") or []
83
+ if len(plans) < lo:
84
+ raise NothingToDo(f"The sources picked do not have enough in them for a {'short' if length == 'short' else 'full'} "
85
+ "deck. Add posts, or choose Short.")
86
+ # The request decides what the deck is about: when the outline misses something it asked for, plan once more with
87
+ # the missing asks spelled out (kept only if the new outline is still long enough).
88
+ missed = _missed(db, job, artifact, request, plans)
89
+ if missed:
90
+ retry = f"{request}\n\nThe previous outline missed these asks; plan slides for them as far as the material supports: " \
91
+ + "; ".join(missed)
92
+ try:
93
+ out = run.predict(OutlineDeck, db=db, workspace_id=artifact.workspace_id, job=job, lm=report_lm(),
94
+ title=material.title, material=material.text, request=retry, deck_format=fmt,
95
+ slide_count=count, language=language)
96
+ again = out.get("outline") or {}
97
+ again_plans = [p for p in again.get("slides") or []
98
+ if isinstance(p, dict) and p.get("layout") in CONTENT_LAYOUTS and plain(p.get("heading"))]
99
+ if len(again_plans) >= lo:
100
+ best, plans = {**again, "slides": again_plans}, again_plans
101
+ except Exception:
102
+ pass # keep the first outline
103
+ # Too many: keep the first and last (the argument's start and end) and drop from the middle.
104
+ while len(plans) > hi:
105
+ plans.pop(len(plans) // 2)
106
+ best["slides"] = plans
107
+ return best
108
+
109
+
110
+ def _source_lines(material: Material, refs: list[dict]) -> list[str]:
111
+ """What one slide is written from: the lines it cites with a little around them, or the material itself."""
112
+ out, used = [], 0
113
+ if material.tools:
114
+ for r in refs:
115
+ text = material.tools.quote(r["path"], max(1, r["line_start"] - 2), r["line_end"] + 6) or r["quote"]
116
+ block = f"FILE {r['path']} ({r.get('title') or ''}) from line {max(1, r['line_start'] - 2)}\n{text}"
117
+ if used + len(block) > SOURCE_BUDGET and out:
118
+ break
119
+ out.append(block[:SOURCE_BUDGET])
120
+ used += len(block)
121
+ if out:
122
+ return out
123
+ budget = CONTEXT_BUDGET // max(len(material.text), 1)
124
+ return [t[:budget] for t in material.text]
125
+
126
+
127
+ def _norm(text: str) -> str:
128
+ return re.sub(r"[^\w%$€£]+", " ", text.lower()).strip()
129
+
130
+
131
+ def _fallback_slide(plan: dict, sources: list[str]) -> dict:
132
+ """A slide written by code when the model could not write one: its planned heading and its first source lines."""
133
+ body = " ".join(re.sub(r"^\s*\d+\|\s?", "", line) for s in sources for line in s.splitlines()[1:])
134
+ sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", body) if len(s.strip()) > 30][:3]
135
+ return {"layout": "points" if len(sentences) >= 2 else "section", "heading": plan.get("heading"),
136
+ "kicker": "", "lead": plan.get("purpose") or "", "points": [{"term": "", "text": s} for s in sentences],
137
+ "notes": plan.get("purpose") or ""}
138
+
139
+
140
+ def _claims(slide: dict) -> list[tuple[tuple, str]]:
141
+ """Every checkable statement on a slide, with where it lives."""
142
+ out: list[tuple[tuple, str]] = []
143
+ if slide.get("layout") == "section" and slide.get("heading"): # a section's heading is its claim
144
+ out.append((("heading",), slide["heading"]))
145
+ if slide.get("lead"):
146
+ out.append((("lead",), slide["lead"]))
147
+ for i, p in enumerate(slide.get("points") or []):
148
+ if p.get("text"): # the term is context; only the sentence is checked and rewritten
149
+ out.append((("points", i), f"{p['term']}: {p['text']}" if p.get("term") else p["text"]))
150
+ for side in ("left", "right"):
151
+ for i, item in enumerate((slide.get(side) or {}).get("items") or []):
152
+ out.append(((side, i), item))
153
+ stat = slide.get("stat") or {}
154
+ if stat.get("value") and stat.get("label"):
155
+ out.append((("stat",), f"{stat['value']} {stat['label']}"))
156
+ return out
157
+
158
+
159
+ def _apply_checks(slide: dict, claims: list[tuple[tuple, str]], checks: list[dict]) -> None:
160
+ """Replace unsupported claims with their fix, or remove them."""
161
+ drop: dict[str, set[int]] = {"points": set(), "left": set(), "right": set()}
162
+ for c in checks or []:
163
+ try:
164
+ n = int(c.get("n")) - 1
165
+ except (TypeError, ValueError):
166
+ continue
167
+ if not 0 <= n < len(claims) or c.get("supported") is not False:
168
+ continue
169
+ where, _ = claims[n]
170
+ fix = plain(c.get("fix"))
171
+ if where[0] == "heading":
172
+ if fix:
173
+ slide["heading"] = fix
174
+ elif where[0] == "lead":
175
+ slide["lead"] = fix
176
+ elif where[0] == "points":
177
+ term = slide["points"][where[1]].get("term") or ""
178
+ if term and fix.lower().startswith(term.lower()): # the fix repeated the name: keep only the sentence
179
+ fix = fix[len(term):].lstrip(" :.-")
180
+ elif term and re.match(r"^[^:.!?]{1,40}:\s", fix): # or put a label of its own before it
181
+ fix = fix.split(":", 1)[1].strip()
182
+ if fix:
183
+ slide["points"][where[1]]["text"] = fix[:1].upper() + fix[1:]
184
+ else:
185
+ drop["points"].add(where[1])
186
+ elif where[0] in ("left", "right"):
187
+ if fix:
188
+ slide[where[0]]["items"][where[1]] = fix
189
+ else:
190
+ drop[where[0]].add(where[1])
191
+ elif where[0] == "stat":
192
+ slide["stat"] = None # a number is never rewritten: it is in the sources as it is, or it goes
193
+ slide["points"] = [p for i, p in enumerate(slide.get("points") or []) if i not in drop["points"]]
194
+ for side in ("left", "right"):
195
+ if slide.get(side):
196
+ slide[side]["items"] = [t for i, t in enumerate(slide[side]["items"]) if i not in drop[side]]
197
+
198
+
199
+ def _verbatim(slide: dict, sources: list[str]) -> None:
200
+ """A quote must be in the sources word for word and a number as written; otherwise it is removed."""
201
+ text = _norm(" ".join(re.sub(r"^\s*\d+\|\s?", "", line) for s in sources for line in s.splitlines()))
202
+ quote = slide.get("quote") or {}
203
+ if quote.get("text") and _norm(quote["text"]) not in text:
204
+ slide["quote"] = None
205
+ stat = slide.get("stat") or {}
206
+ if stat.get("value") and _norm(stat["value"]) not in text:
207
+ slide["stat"] = None
208
+
209
+
210
+ def _summary(slide: dict) -> str:
211
+ bits = [slide.get("heading") or "", slide.get("lead") or ""]
212
+ bits += [f"{p.get('term') or ''} {p.get('text') or ''}".strip() for p in slide.get("points") or []]
213
+ for side in ("left", "right"):
214
+ col = slide.get(side) or {}
215
+ if col.get("items"):
216
+ bits.append(f"{col.get('label') or ''}: " + "; ".join(col["items"]))
217
+ if (slide.get("stat") or {}).get("value"):
218
+ bits.append(f"{slide['stat']['value']} {slide['stat'].get('label') or ''}")
219
+ if (slide.get("quote") or {}).get("text"):
220
+ bits.append(f"“{slide['quote']['text']}”")
221
+ return " | ".join(b for b in bits if b)
222
+
223
+
224
+ def _check_frame(db: Session, job: Job, ws: uuid.UUID, slides: list[dict], subtitle: str, takeaways: list[str],
225
+ last_line: str) -> tuple[str, list[str], str]:
226
+ """The opening's subtitle, the takeaways and the closing line say only what the (already checked) slides say:
227
+ each is checked against them, and fixed or removed when it does not."""
228
+ claims = ([("subtitle", subtitle)] if subtitle else []) + [("takeaway", t) for t in takeaways] + \
229
+ ([("closing", last_line)] if last_line else [])
230
+ if not claims:
231
+ return subtitle, takeaways, last_line
232
+ try:
233
+ out = run.predict(CheckSlide, db=db, workspace_id=ws, job=job, lm=fast_lm(),
234
+ claims=[f"{n + 1}. {t}" for n, (_, t) in enumerate(claims)],
235
+ source_lines=[_summary(s) for s in slides])
236
+ except Exception:
237
+ return subtitle, takeaways, last_line
238
+ verdict: dict[int, str | None] = {} # claim index -> its fix ("" to remove)
239
+ for c in out.get("checks") or []:
240
+ try:
241
+ n = int(c.get("n")) - 1
242
+ except (TypeError, ValueError):
243
+ continue
244
+ if 0 <= n < len(claims) and c.get("supported") is False:
245
+ verdict[n] = plain(c.get("fix"))
246
+ kept = [verdict.get(n, text) for n, (_, text) in enumerate(claims)]
247
+ subtitle = kept.pop(0) if subtitle else ""
248
+ last_line = kept.pop() if last_line else ""
249
+ return subtitle, [t for t in kept if t] or [s["heading"] for s in slides[:3]], last_line
250
+
251
+
252
+ def _previous_variants(db: Session, artifact: Artifact) -> list[tuple[str, str]]:
253
+ """The layout and variant of each slide in the notebook's last deck, so this one can look different."""
254
+ if not artifact.notebook_id:
255
+ return []
256
+ last = db.scalars(select(Artifact).where(Artifact.notebook_id == artifact.notebook_id, Artifact.type == "slide_deck",
257
+ Artifact.status == "ready", Artifact.id != artifact.id)
258
+ .order_by(Artifact.created_at.desc()).limit(1)).first()
259
+ slides = (((last.content_json or {}).get("deck") or {}).get("slides") or []) if last else []
260
+ return [(s.get("layout"), s.get("variant")) for s in slides if isinstance(s, dict)]
261
+
262
+
263
+ def build_slide_deck(db: Session, job: Job, artifact: Artifact) -> dict:
264
+ params = job.params
265
+ ws = artifact.workspace_id
266
+ fmt = params.get("deck_format") if params.get("deck_format") in FORMATS else "detailed"
267
+ length = params.get("deck_length") if params.get("deck_length") in LENGTHS else "default"
268
+ theme = theme_id(params.get("theme"))
269
+ language = (params.get("language") or "").strip() or SAME_LANGUAGE
270
+ request = (params.get("instructions") or "").strip()
271
+ material = load_material(db, ws, artifact.notebook_id, params, job=job, what="slide deck")
272
+
273
+ update_job(db, job, progress=0.2, message="Planning the story")
274
+ outline = _outline(db, job, artifact, material, fmt, length, request, language)
275
+ plans = outline["slides"]
276
+ refs = [material.refs(p.get("sources")) for p in plans]
277
+ sources = [_source_lines(material, r) for r in refs]
278
+ lines = [f"{i + 2}. {p['heading']}" for i, p in enumerate(plans)]
279
+ total = len(plans) + (3 if has_agenda(length) else 2)
280
+
281
+ update_job(db, job, progress=0.35, message=f"Writing {len(plans)} slides")
282
+ first = 3 if has_agenda(length) else 2 # where content starts, counting from 1
283
+
284
+ def inputs(i: int) -> dict:
285
+ return {"deck_title": outline.get("title") or material.title, "outline": lines,
286
+ "position": f"Slide {first + i} of {total}", "layout": plans[i]["layout"],
287
+ "heading": plans[i]["heading"], "purpose": plans[i].get("purpose") or "", "deck_format": fmt,
288
+ "request": request or "(none)", "source_lines": sources[i], "language": language}
289
+
290
+ written = run.predict_many(WriteSlide, [inputs(i) for i in range(len(plans))], db=db, workspace_id=ws, lm=report_lm())
291
+ missing = [i for i, w in enumerate(written) if not (w and w.get("slide"))]
292
+ if missing: # one more try for the slides that failed
293
+ again = run.predict_many(WriteSlide, [inputs(i) for i in missing], db=db, workspace_id=ws, lm=report_lm())
294
+ for i, w in zip(missing, again, strict=True):
295
+ written[i] = w
296
+ slides = []
297
+ for i, w in enumerate(written):
298
+ s = (w or {}).get("slide") or _fallback_slide(plans[i], sources[i])
299
+ s = dict(s)
300
+ s["layout"] = plans[i]["layout"] if not s.get("layout") else s["layout"]
301
+ s["heading"] = s.get("heading") or plans[i]["heading"]
302
+ cited = material.refs(s.get("sources")) if material.tools else []
303
+ s["sources"] = cited or refs[i]
304
+ _verbatim(s, sources[i])
305
+ slides.append(s)
306
+
307
+ update_job(db, job, progress=0.6, message="Checking every slide against your posts")
308
+ claims = [_claims(s) for s in slides]
309
+ todo = [i for i, c in enumerate(claims) if c]
310
+ checked = run.predict_many(
311
+ CheckSlide, [{"claims": [f"{n + 1}. {t}" for n, (_, t) in enumerate(claims[i])], "source_lines": sources[i]}
312
+ for i in todo], db=db, workspace_id=ws, lm=fast_lm())
313
+ for i, out in zip(todo, checked, strict=True):
314
+ if out:
315
+ _apply_checks(slides[i], claims[i], out.get("checks") or [])
316
+
317
+ # A slide left with only its heading (its number or quote was not in the posts, its claims did not hold) says
318
+ # nothing checked: it goes, as long as the deck keeps its length.
319
+ lo = content_range(length)[0]
320
+ for i in reversed(range(len(slides))):
321
+ bare = (clamp_slide({**slides[i], "layout": slides[i].get("layout")}, fmt) or {}).get("layout") == "section"
322
+ if bare and plans[i]["layout"] != "section" and len(slides) > lo:
323
+ del slides[i]
324
+ # Two slides with the same heading (a part's title repeated by its first slide): the section one goes.
325
+ for i in reversed(range(len(slides))):
326
+ same = [j for j, o in enumerate(slides) if j != i and _norm(o.get("heading") or "") == _norm(slides[i].get("heading") or "")]
327
+ if same and slides[i].get("layout") == "section" and len(slides) > lo:
328
+ del slides[i]
329
+
330
+ update_job(db, job, progress=0.8, message="Writing the conclusion")
331
+ closing: dict = {}
332
+ for _ in range(2): # one more try: a failed call would leave the deck without its real conclusion
333
+ try:
334
+ closing = run.predict(WriteClosing, db=db, workspace_id=ws, job=job, lm=fast_lm(),
335
+ deck_title=outline.get("title") or material.title, slides=[_summary(s) for s in slides],
336
+ deck_format=fmt, request=request or "(none)", language=language).get("closing") or {}
337
+ except Exception:
338
+ log.warning("WriteClosing failed", exc_info=True)
339
+ closing = {}
340
+ if closing.get("takeaways"):
341
+ break
342
+ takeaways = closing.get("takeaways") or [s["heading"] for s in slides[:3]]
343
+ subtitle = outline.get("subtitle") or ""
344
+ subtitle, takeaways, last_line = _check_frame(db, job, ws, slides, subtitle, takeaways, closing.get("closing") or "")
345
+
346
+ opening = {"layout": "title", "kicker": outline.get("opening_kicker") or "", "heading": outline.get("title") or material.title,
347
+ "lead": subtitle, "notes": subtitle}
348
+ deck_slides = [opening]
349
+ if has_agenda(length):
350
+ # A long deck's agenda lists its parts (its section slides); otherwise every slide.
351
+ parts = [s for s in slides if s.get("layout") == "section"]
352
+ listed = parts if len(parts) >= 2 else slides
353
+ deck_slides.append({"layout": "agenda", "kicker": "", "heading": outline.get("agenda_heading") or "Agenda",
354
+ "points": [{"term": "", "text": s["heading"]} for s in listed]})
355
+ deck_slides += slides
356
+ deck_slides.append({"layout": "closing", "kicker": closing.get("kicker") or "",
357
+ "heading": closing.get("heading") or opening["heading"], "takeaways": takeaways,
358
+ "closing": last_line or opening["lead"], "notes": closing.get("notes") or ""})
359
+
360
+ update_job(db, job, progress=0.9, message="Laying out the slides")
361
+ deck = clamp_deck({"title": opening["heading"], "subtitle": opening["lead"], "slides": deck_slides}, fmt)
362
+ if not deck or len(deck["slides"]) < LENGTHS[length][0]:
363
+ raise NothingToDo("The deck came out too thin to show. Try other posts or add a description.")
364
+ seed = artifact.id.hex if isinstance(artifact.id, uuid.UUID) else str(artifact.id)
365
+ deck = finish(deck, theme, fmt, seed_of(seed), _previous_variants(db, artifact))
366
+
367
+ artifact.content_json = {**(artifact.content_json or {}), "title": f"Slide deck: {deck['title']}"[:140],
368
+ "theme": theme, "format": fmt, "length": length,
369
+ "language": params.get("language") or "", "instructions": request,
370
+ "source": material.source, "seed": seed, "deck": deck}
371
+ artifact.storage_key = None
372
+ artifact.status = "ready"
373
+ db.commit()
374
+ return {"artifact_id": str(artifact.id), "slides": len(deck["slides"])}
backend/app/routers/artifacts.py CHANGED
@@ -18,14 +18,21 @@ from app.services.plans import effective_plan, plan_limit_error
18
  from app.services.renderer import COMPOSITIONS
19
  from app.services.storage import storage
20
  from app.services.usage import check_limit
 
 
 
 
 
 
21
 
22
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
23
 
24
  ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
25
- "flashcards", "report", "infographic"]
26
 
27
 
28
- SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic") # made from posts or chats picked in the dialog
 
29
 
30
 
31
  class RenderIn(BaseModel):
@@ -39,7 +46,8 @@ class GenerateIn(BaseModel):
39
  document_id: uuid.UUID | None = None
40
  archive: bool = False # no notebook or post picked: use the "All posts" notebook
41
  # audio_overview
42
- format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
 
43
  minutes: int = Field(6, ge=1, le=30)
44
  # the two hosts' voices, from the workspace's voices (none: the defaults)
45
  host_a: str | None = Field(None, max_length=100)
@@ -61,7 +69,10 @@ class GenerateIn(BaseModel):
61
  question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
62
  default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
63
  language: str | None = Field(None, max_length=40)
64
- theme: str | None = Field(None, max_length=30) # infographic: one of app.infographics.themes
 
 
 
65
  # report: a written "document" or an "interactive" one, the template it started from, and the instructions the
66
  # writer is given (the dialog's editable text; empty means the template's own)
67
  report_format: Literal["document", "interactive"] = "document"
@@ -152,8 +163,16 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
152
  raise HTTPException(400, "Pick at least one post or chat to make this from")
153
  target = _target_title(ctx, body.notebook_id, body.document_id)
154
  if body.type == "audio_overview":
 
 
155
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
156
- params = {"format": body.format, "minutes": body.minutes, **_hosts(ctx, body)}
 
 
 
 
 
 
157
  title = f"Audio overview: {target}"
158
  elif body.type == "mind_map":
159
  if not body.notebook_id:
@@ -180,6 +199,12 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
180
  params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
181
  "theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
182
  title = f"Infographic: {params['instructions'][:60] or target}"
 
 
 
 
 
 
183
  elif body.type == "quiz":
184
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
185
  params = {"document_ids": [str(i) for i in ids],
@@ -257,6 +282,63 @@ def infographic_image(artifact_id: uuid.UUID, layout: Literal["landscape", "port
257
  return Response(png, media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{name} ({layout}).png"'})
258
 
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  @router.patch("/{artifact_id}")
261
  def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
262
  a = get_artifact_or_404(ctx, artifact_id)
 
18
  from app.services.renderer import COMPOSITIONS
19
  from app.services.storage import storage
20
  from app.services.usage import check_limit
21
+ from app.slides import export as slide_export
22
+ from app.slides.build import BadEdit, restructure
23
+ from app.slides.build import slots as slide_slots
24
+ from app.slides.build import stored as stored_deck
25
+ from app.slides.build import view as slide_pages
26
+ from app.slides.themes import theme_id as slide_theme_id
27
 
28
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
29
 
30
  ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
31
+ "flashcards", "report", "infographic", "slide_deck"]
32
 
33
 
34
+ # Made from posts or chats picked in the dialog
35
+ SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic", "slide_deck", "audio_overview")
36
 
37
 
38
  class RenderIn(BaseModel):
 
46
  document_id: uuid.UUID | None = None
47
  archive: bool = False # no notebook or post picked: use the "All posts" notebook
48
  # audio_overview
49
+ format: Literal["deep_dive", "brief", "critique", "debate"] = "deep_dive"
50
+ hosts: Literal[1, 2] = 2 # audio_overview: a single narrator or a two host conversation
51
  minutes: int = Field(6, ge=1, le=30)
52
  # the two hosts' voices, from the workspace's voices (none: the defaults)
53
  host_a: str | None = Field(None, max_length=100)
 
69
  question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
70
  default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
71
  language: str | None = Field(None, max_length=40)
72
+ theme: str | None = Field(None, max_length=30) # infographic: app.infographics.themes; slide_deck: app.slides.themes
73
+ # slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
74
+ deck_format: Literal["detailed", "presenter"] = "detailed"
75
+ deck_length: Literal["short", "default", "long"] = "default"
76
  # report: a written "document" or an "interactive" one, the template it started from, and the instructions the
77
  # writer is given (the dialog's editable text; empty means the template's own)
78
  report_format: Literal["document", "interactive"] = "document"
 
163
  raise HTTPException(400, "Pick at least one post or chat to make this from")
164
  target = _target_title(ctx, body.notebook_id, body.document_id)
165
  if body.type == "audio_overview":
166
+ if body.format == "debate" and body.hosts == 1:
167
+ raise HTTPException(400, "A debate needs two hosts")
168
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
169
+ ids = body.document_ids or ([body.document_id] if body.document_id else [])
170
+ hosts = _hosts(ctx, body)
171
+ if body.hosts == 1:
172
+ hosts.pop("host_b", None)
173
+ params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
174
+ "format": body.format, "minutes": body.minutes, "hosts": body.hosts, "language": body.language,
175
+ "instructions": (body.instructions or "").strip(), **hosts}
176
  title = f"Audio overview: {target}"
177
  elif body.type == "mind_map":
178
  if not body.notebook_id:
 
199
  params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
200
  "theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
201
  title = f"Infographic: {params['instructions'][:60] or target}"
202
+ elif body.type == "slide_deck":
203
+ ids = body.document_ids or ([body.document_id] if body.document_id else [])
204
+ params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
205
+ "theme": slide_theme_id(body.theme), "deck_format": body.deck_format, "deck_length": body.deck_length,
206
+ "language": body.language, "instructions": (body.instructions or "").strip()}
207
+ title = f"Slide deck: {target}"
208
  elif body.type == "quiz":
209
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
210
  params = {"document_ids": [str(i) for i in ids],
 
282
  return Response(png, media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{name} ({layout}).png"'})
283
 
284
 
285
+ @router.get("/{artifact_id}/slides.{ext}")
286
+ def slide_deck_file(artifact_id: uuid.UUID, ext: Literal["pdf", "pptx"], ctx: Ctx = Depends(get_ctx)):
287
+ """A finished slide deck as a PDF (exactly as shown) or an editable PowerPoint."""
288
+ a = get_artifact_or_404(ctx, artifact_id)
289
+ got = stored_deck(a.content_json or {}) if a.type == "slide_deck" and a.status == "ready" else None
290
+ if not got:
291
+ raise HTTPException(404, "This slide deck has nothing to download yet.")
292
+ deck, theme, fmt, seed = got
293
+ try:
294
+ data = (slide_export.render_pdf if ext == "pdf" else slide_export.build_pptx)(deck, theme, fmt, seed)
295
+ except ImageUnavailable as exc:
296
+ raise HTTPException(503, str(exc)) from exc
297
+ name = re.sub(r"[^\w\- ]+", "", deck.get("title") or "Slide deck").strip()[:80] or "Slide deck"
298
+ media = "application/pdf" if ext == "pdf" else \
299
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation"
300
+ return Response(data, media_type=media, headers={"Content-Disposition": f'attachment; filename="{name}.{ext}"'})
301
+
302
+
303
+ class DeckIn(BaseModel):
304
+ # The deck's slides as the editor has them, in the stored shape (app.slides.content.empty_slide); coerced on arrival
305
+ slides: list[dict] = Field(min_length=1, max_length=60)
306
+
307
+
308
+ def _editable_deck(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
309
+ a = get_artifact_or_404(ctx, artifact_id)
310
+ if a.type != "slide_deck" or a.status != "ready":
311
+ raise HTTPException(400, "Only a finished slide deck can be edited.")
312
+ return a
313
+
314
+
315
+ @router.post("/{artifact_id}/deck-preview")
316
+ def preview_deck(artifact_id: uuid.UUID, body: DeckIn, ctx: Ctx = Depends(get_ctx)):
317
+ """The editor's slides drawn, without saving: after a slide is added, removed, moved or changed to another layout,
318
+ or a text box is added or removed. Returns the slides as they will be kept (variants given, text fitted)."""
319
+ a = _editable_deck(ctx, artifact_id)
320
+ try:
321
+ deck = restructure(a.content_json or {}, body.slides, final=False)
322
+ except BadEdit as exc:
323
+ raise HTTPException(400, str(exc)) from exc
324
+ content = {**(a.content_json or {}), "deck": deck}
325
+ return {"slides": deck["slides"], "slides_html": slide_pages(content), "slide_slots": slide_slots(content)}
326
+
327
+
328
+ @router.put("/{artifact_id}/deck")
329
+ def save_deck(artifact_id: uuid.UUID, body: DeckIn, ctx: Ctx = Depends(get_ctx)):
330
+ """The editor's slides kept: text, speaker notes, and which slides there are, in what order and layout. The deck is
331
+ fitted again (with the Chrome check), so the slides, the PDF and the PowerPoint all show it."""
332
+ a = _editable_deck(ctx, artifact_id)
333
+ try:
334
+ deck = restructure(a.content_json or {}, body.slides, final=True)
335
+ except BadEdit as exc:
336
+ raise HTTPException(400, str(exc)) from exc
337
+ a.content_json = {**(a.content_json or {}), "deck": deck}
338
+ ctx.db.commit()
339
+ return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
340
+
341
+
342
  @router.patch("/{artifact_id}")
343
  def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
344
  a = get_artifact_or_404(ctx, artifact_id)
backend/app/routers/launchpad.py CHANGED
@@ -21,11 +21,21 @@ from app.config import settings
21
  from app.db import get_db
22
  from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, Upload
23
  from app.services import launchpad as lp
24
- from app.services.artifacts import TYPE_LABELS
25
  from app.services.crypto import decrypt, encrypt
26
  from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
27
  from app.services.social.health import pause_account_posts, resume_account_posts
28
- from app.services.social.media import MB, SIZE_LIMITS, UPLOAD_TYPES, check_postable, duration, is_blog2video, media_count, upload_kind
 
 
 
 
 
 
 
 
 
 
29
  from app.services.storage import storage
30
 
31
  router = APIRouter(prefix="/api", tags=["launchpad"])
@@ -61,7 +71,7 @@ class When(BaseModel):
61
 
62
  class ItemIn(When):
63
  platform: Platform
64
- content: str = Field(min_length=1, max_length=10000)
65
  thread: list[str] = Field(default_factory=list, max_length=25)
66
  artifact_id: uuid.UUID | None = None # the attachment (a video, quote card, carousel...), never a Launch Kit
67
  kit_id: uuid.UUID | None = None # the Launch Kit the post was written from (tracked, not attached)
@@ -69,11 +79,13 @@ class ItemIn(When):
69
  social_account_id: uuid.UUID | None = None
70
  remind_by_email: bool = False
71
  draft: bool = False
 
 
72
 
73
 
74
  class ItemPatch(When):
75
  artifact_id: uuid.UUID | None = None # set or (null) clear what the post carries
76
- content: str | None = Field(None, min_length=1, max_length=10000)
77
  thread: list[str] | None = None
78
  social_account_id: uuid.UUID | None = None
79
  remind_by_email: bool | None = None
@@ -85,7 +97,9 @@ def _aware(dt: datetime) -> datetime:
85
 
86
 
87
  # audio can't be posted; "upload" is a file the user brought from their computer
88
- POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload")
 
 
89
 
90
 
91
  def _cite_free(text: str) -> str:
@@ -107,22 +121,38 @@ def _prefill(a: Artifact) -> dict:
107
  "quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
108
  else title,
109
  "carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
 
110
  }.get(a.type, title)
111
  return {"x": [text], "linkedin": text}
112
 
113
 
 
 
 
 
 
 
 
 
 
 
114
  def artifact_brief(a: Artifact) -> dict:
115
  """What the composer and the calendar show of the attached artifact."""
116
  c = a.content_json or {}
117
  thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
118
  else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
119
  return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
120
- "title": c.get("title") or TYPE_LABELS.get(a.type, a.type),
121
- "media": upload_kind(a) or ("video" if a.type == "video" else "image" if media_count(a) else "text"),
 
 
122
  "media_count": media_count(a), "duration_s": duration(a),
123
  "thumb_url": storage.presign_get(thumb) if thumb else None,
124
  # The user's own file, to preview before posting it.
125
  "view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
 
 
 
126
  # A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
127
  # Made by the video service (blog2video): it opens in the video editor.
128
  "editable_video": is_blog2video(a),
@@ -197,6 +227,12 @@ def _require_connected(platform: str, account: SocialAccount | None, artifact: A
197
  raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})
198
 
199
 
 
 
 
 
 
 
200
  def _validate_lengths(platform: str, posts: list[str]) -> None:
201
  limit = LIMITS.get(platform)
202
  if platform in ("x", "bluesky") and limit:
@@ -251,12 +287,13 @@ def list_items(ctx: Ctx = Depends(get_ctx), start: datetime | None = None, end:
251
 
252
  @router.post("/calendar")
253
  def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
254
- when = body.utc()
255
  if when is None:
256
  raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
257
  account = _account(ctx, body.social_account_id, body.platform)
258
  _validate_lengths(body.platform, [body.content, *body.thread])
259
  artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
 
260
  kit = _kit(ctx, body.kit_id) if body.kit_id else None
261
  _require_connected(body.platform, account, artifact)
262
  if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
@@ -268,13 +305,24 @@ def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
268
  kit_id=kit.id if kit else None,
269
  document_id=body.document_id, social_account_id=account.id if account else None,
270
  remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
271
- status="draft" if body.draft else "scheduled",
272
  )
273
  ctx.db.add(item)
274
  ctx.db.commit()
 
 
275
  return _enrich(ctx, [item])[0]
276
 
277
 
 
 
 
 
 
 
 
 
 
278
  @router.patch("/calendar/{item_id}")
279
  def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)):
280
  item = _item(ctx, item_id)
@@ -301,15 +349,17 @@ def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)
301
  if body.status == "paused":
302
  item.status = "paused" # held by hand: never attempted until resumed
303
  elif body.status is not None or item.status in ("failed", "reminded", "paused"):
304
- # Going out (again): only through a live connection, and only at a time still ahead.
 
305
  _require_connected(item.platform, account, artifact)
306
- if item.status == "paused" and _aware(item.scheduled_at) <= datetime.now(UTC):
307
  raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
308
  item.status = body.status or "scheduled"
309
  elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
310
  _require_connected(item.platform, account, artifact)
311
  item.error = None
312
  item.attempts = 0
 
313
  _validate_lengths(item.platform, [item.content, *(item.thread or [])])
314
  ctx.db.commit()
315
  return _enrich(ctx, [item])[0]
@@ -324,17 +374,16 @@ def delete_item(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
324
 
325
 
326
  @router.post("/calendar/{item_id}/publish")
327
- async def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
 
 
328
  item = _item(ctx, item_id)
329
  if item.status in ("posted", "publishing"):
330
  raise HTTPException(409, "This post already went out.")
331
  item.status = "publishing"
332
  item.attempts = 0
333
  ctx.db.commit()
334
- if lp.has_media(ctx.db, item):
335
- lp.queue_publish(ctx.db, item) # uploading takes a while: a job does it, the page follows the status
336
- else:
337
- await run_in_threadpool(lp.publish_item, ctx.db, item)
338
  return _enrich(ctx, [item])[0]
339
 
340
 
@@ -373,11 +422,12 @@ def register_upload(body: UploadIn, ctx: Ctx = Depends(get_ctx)):
373
 
374
  @router.get("/launchpad/postable")
375
  def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
376
- """What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps
377
- (not audio), newest first. Videos made but not rendered yet are listed too, flagged needs_render (they can only be
378
- posted once rendered), and so are videos being rendered, flagged rendering."""
 
379
  rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
380
- Artifact.type.in_(POSTABLE))
381
  .order_by(Artifact.created_at.desc()).limit(400))
382
  needle = q.strip().lower()
383
  out = []
@@ -391,6 +441,18 @@ def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
391
  out.append(artifact_brief(a))
392
  if len(out) >= min(limit, 200):
393
  break
 
 
 
 
 
 
 
 
 
 
 
 
394
  return out
395
 
396
 
 
21
  from app.db import get_db
22
  from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, Upload
23
  from app.services import launchpad as lp
24
+ from app.services.artifacts import TYPE_LABELS, download_name
25
  from app.services.crypto import decrypt, encrypt
26
  from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
27
  from app.services.social.health import pause_account_posts, resume_account_posts
28
+ from app.services.social.media import (
29
+ MB,
30
+ SIZE_LIMITS,
31
+ UPLOAD_TYPES,
32
+ carries_media,
33
+ check_postable,
34
+ duration,
35
+ is_blog2video,
36
+ media_count,
37
+ upload_kind,
38
+ )
39
  from app.services.storage import storage
40
 
41
  router = APIRouter(prefix="/api", tags=["launchpad"])
 
71
 
72
  class ItemIn(When):
73
  platform: Platform
74
+ content: str = Field("", max_length=10000) # may be empty when the post carries images or a video
75
  thread: list[str] = Field(default_factory=list, max_length=25)
76
  artifact_id: uuid.UUID | None = None # the attachment (a video, quote card, carousel...), never a Launch Kit
77
  kit_id: uuid.UUID | None = None # the Launch Kit the post was written from (tracked, not attached)
 
79
  social_account_id: uuid.UUID | None = None
80
  remind_by_email: bool = False
81
  draft: bool = False
82
+ # Post now: published (or, by email, reminded) in this request, never left scheduled; no time needed
83
+ publish_now: bool = False
84
 
85
 
86
  class ItemPatch(When):
87
  artifact_id: uuid.UUID | None = None # set or (null) clear what the post carries
88
+ content: str | None = Field(None, max_length=10000)
89
  thread: list[str] | None = None
90
  social_account_id: uuid.UUID | None = None
91
  remind_by_email: bool | None = None
 
97
 
98
 
99
  # audio can't be posted; "upload" is a file the user brought from their computer
100
+ POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload", "slide_deck")
101
+ # Listed on the Launchpad too, but only to download: X and LinkedIn take no audio files (check_postable refuses it)
102
+ LISTED = (*POSTABLE, "audio_overview")
103
 
104
 
105
  def _cite_free(text: str) -> str:
 
121
  "quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
122
  else title,
123
  "carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
124
+ "slide_deck": _deck_prefill(c) or title,
125
  }.get(a.type, title)
126
  return {"x": [text], "linkedin": text}
127
 
128
 
129
+ def _deck_prefill(c: dict) -> str:
130
+ """A slide deck's post: its title, its subtitle, and the takeaways of its closing slide."""
131
+ deck = c.get("deck") or {}
132
+ slides = deck.get("slides") or []
133
+ closing = next((s for s in reversed(slides) if s.get("layout") == "closing"), {})
134
+ head = "\n\n".join(x for x in (deck.get("title"), deck.get("subtitle")) if x)
135
+ takeaways = "\n".join(f"- {t}" for t in closing.get("takeaways") or [])
136
+ return "\n\n".join(x for x in (head, takeaways) if x)
137
+
138
+
139
  def artifact_brief(a: Artifact) -> dict:
140
  """What the composer and the calendar show of the attached artifact."""
141
  c = a.content_json or {}
142
  thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
143
  else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
144
  return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
145
+ # A deck's title starts "Slide deck: ", which its tag already says
146
+ "title": (c.get("title") or TYPE_LABELS.get(a.type, a.type)).removeprefix("Slide deck: " if a.type == "slide_deck" else ""),
147
+ "media": upload_kind(a) or ("video" if a.type == "video" else "audio" if a.type == "audio_overview"
148
+ else "image" if media_count(a) else "text"),
149
  "media_count": media_count(a), "duration_s": duration(a),
150
  "thumb_url": storage.presign_get(thumb) if thumb else None,
151
  # The user's own file, to preview before posting it.
152
  "view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
153
+ # An audio overview can't be posted: it is offered to download instead.
154
+ "download_url": (storage.presign_get(a.storage_key, download_name=download_name(a))
155
+ if a.type == "audio_overview" and a.storage_key else None),
156
  # A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
157
  # Made by the video service (blog2video): it opens in the video editor.
158
  "editable_video": is_blog2video(a),
 
227
  raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})
228
 
229
 
230
+ def _require_words(posts: list[str], artifact: Artifact | None) -> None:
231
+ """A post needs words, unless it carries images or a video (those can go out without a caption)."""
232
+ if not any(p.strip() for p in posts) and not carries_media(artifact):
233
+ raise HTTPException(422, {"code": "no_text", "message": "Write something to post, or attach an image or video."})
234
+
235
+
236
  def _validate_lengths(platform: str, posts: list[str]) -> None:
237
  limit = LIMITS.get(platform)
238
  if platform in ("x", "bluesky") and limit:
 
287
 
288
  @router.post("/calendar")
289
  def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
290
+ when = datetime.now(UTC) if body.publish_now else body.utc()
291
  if when is None:
292
  raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
293
  account = _account(ctx, body.social_account_id, body.platform)
294
  _validate_lengths(body.platform, [body.content, *body.thread])
295
  artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
296
+ _require_words([body.content, *body.thread], artifact)
297
  kit = _kit(ctx, body.kit_id) if body.kit_id else None
298
  _require_connected(body.platform, account, artifact)
299
  if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
 
305
  kit_id=kit.id if kit else None,
306
  document_id=body.document_id, social_account_id=account.id if account else None,
307
  remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
308
+ status="draft" if body.draft else "publishing" if body.publish_now else "scheduled",
309
  )
310
  ctx.db.add(item)
311
  ctx.db.commit()
312
+ if body.publish_now:
313
+ _go_now(ctx, item)
314
  return _enrich(ctx, [item])[0]
315
 
316
 
317
+ def _go_now(ctx: Ctx, item: CalendarItem) -> None:
318
+ """Publish an item claimed for it ("publishing") now: with images or a video through a job (uploading takes a
319
+ while; the page follows its status), anything else right here."""
320
+ if lp.has_media(ctx.db, item):
321
+ lp.queue_publish(ctx.db, item)
322
+ else:
323
+ lp.publish_item(ctx.db, item)
324
+
325
+
326
  @router.patch("/calendar/{item_id}")
327
  def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)):
328
  item = _item(ctx, item_id)
 
349
  if body.status == "paused":
350
  item.status = "paused" # held by hand: never attempted until resumed
351
  elif body.status is not None or item.status in ("failed", "reminded", "paused"):
352
+ # Going out (again), rescheduled: only through a live connection, and only at a time still ahead (a failed or
353
+ # reminded post's old time has passed, and it would otherwise go out at the very next run).
354
  _require_connected(item.platform, account, artifact)
355
+ if (body.status or "scheduled") == "scheduled" and _aware(item.scheduled_at) <= datetime.now(UTC):
356
  raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
357
  item.status = body.status or "scheduled"
358
  elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
359
  _require_connected(item.platform, account, artifact)
360
  item.error = None
361
  item.attempts = 0
362
+ _require_words([item.content or "", *(item.thread or [])], artifact) # e.g. the caption emptied, or the video removed
363
  _validate_lengths(item.platform, [item.content, *(item.thread or [])])
364
  ctx.db.commit()
365
  return _enrich(ctx, [item])[0]
 
374
 
375
 
376
  @router.post("/calendar/{item_id}/publish")
377
+ def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
378
+ """Publish a scheduled post now. A plain (threadpool) endpoint: its database calls and an inline publish must
379
+ never hold up the event loop, and with it every other request."""
380
  item = _item(ctx, item_id)
381
  if item.status in ("posted", "publishing"):
382
  raise HTTPException(409, "This post already went out.")
383
  item.status = "publishing"
384
  item.attempts = 0
385
  ctx.db.commit()
386
+ _go_now(ctx, item)
 
 
 
387
  return _enrich(ctx, [item])[0]
388
 
389
 
 
422
 
423
  @router.get("/launchpad/postable")
424
  def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
425
+ """What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps,
426
+ slide decks, newest first, and audio overviews (listed to download: they can't be posted). Videos made but not
427
+ rendered yet are listed too, flagged needs_render (they can only be posted once rendered), and so are videos being
428
+ rendered, flagged rendering."""
429
  rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
430
+ Artifact.type.in_(LISTED))
431
  .order_by(Artifact.created_at.desc()).limit(400))
432
  needle = q.strip().lower()
433
  out = []
 
441
  out.append(artifact_brief(a))
442
  if len(out) >= min(limit, 200):
443
  break
444
+ # What each one already has coming up (scheduled, still ahead), soonest first: the list offers to reschedule it
445
+ upcoming = ctx.db.scalars(select(CalendarItem).where(
446
+ CalendarItem.workspace_id == ctx.workspace.id, CalendarItem.status == "scheduled",
447
+ CalendarItem.scheduled_at > datetime.now(UTC),
448
+ CalendarItem.artifact_id.in_([uuid.UUID(b["id"]) for b in out])).order_by(CalendarItem.scheduled_at))
449
+ by_artifact: dict[str, list[dict]] = {}
450
+ for it in upcoming:
451
+ by_artifact.setdefault(str(it.artifact_id), []).append(
452
+ {"id": str(it.id), "scheduled_at": _aware(it.scheduled_at).isoformat(),
453
+ "platform_label": PLATFORM_LABELS.get(it.platform, it.platform)})
454
+ for b in out:
455
+ b["scheduled"] = by_artifact.get(b["id"], [])
456
  return out
457
 
458
 
backend/app/services/artifacts.py CHANGED
@@ -9,6 +9,8 @@ from sqlalchemy.orm import Session, object_session
9
  from app.models import Artifact, Job
10
  from app.services.jobs import create_job, serialize_job
11
  from app.services.storage import storage
 
 
12
 
13
  TYPE_LABELS = {
14
  "summary": "Summary",
@@ -22,6 +24,7 @@ TYPE_LABELS = {
22
  "flashcards": "Flashcards",
23
  "report": "Report",
24
  "infographic": "Infographic",
 
25
  "upload": "Upload",
26
  }
27
 
@@ -84,6 +87,8 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
84
  from app.pipeline.infographic import infographic_html # not at import: pipeline imports this module
85
 
86
  content = {**content, "html": infographic_html(content), "html_landscape": infographic_html(content, "landscape")}
 
 
87
  if a.type == "report" and any(b.get("type") == "infographic" for b in content.get("blocks") or []):
88
  content = {**content, "blocks": report_blocks(a)}
89
  slides = content.get("slide_keys") or []
@@ -99,7 +104,7 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
99
  "storage_key": a.storage_key,
100
  # blog2video videos live on blog2video's storage (video_url once rendered)
101
  "url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
102
- "download_url": (storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key
103
  else content.get("video_url")),
104
  "provider": content.get("provider"),
105
  "slide_urls": [storage.presign_get(k) for k in slides],
@@ -108,7 +113,8 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
108
  }
109
 
110
 
111
- def _download_name(a: Artifact) -> str:
 
112
  title = (a.content_json or {}).get("title") or a.type
113
  ext = (a.storage_key or "").rsplit(".", 1)[-1]
114
  return f"{title[:60]}.{ext}"
 
9
  from app.models import Artifact, Job
10
  from app.services.jobs import create_job, serialize_job
11
  from app.services.storage import storage
12
+ from app.slides.build import slots as slide_slots
13
+ from app.slides.build import view as slide_pages
14
 
15
  TYPE_LABELS = {
16
  "summary": "Summary",
 
24
  "flashcards": "Flashcards",
25
  "report": "Report",
26
  "infographic": "Infographic",
27
+ "slide_deck": "Slide deck",
28
  "upload": "Upload",
29
  }
30
 
 
87
  from app.pipeline.infographic import infographic_html # not at import: pipeline imports this module
88
 
89
  content = {**content, "html": infographic_html(content), "html_landscape": infographic_html(content, "landscape")}
90
+ if a.type == "slide_deck" and a.status == "ready":
91
+ content = {**content, "slides_html": slide_pages(content), "slide_slots": slide_slots(content)}
92
  if a.type == "report" and any(b.get("type") == "infographic" for b in content.get("blocks") or []):
93
  content = {**content, "blocks": report_blocks(a)}
94
  slides = content.get("slide_keys") or []
 
104
  "storage_key": a.storage_key,
105
  # blog2video videos live on blog2video's storage (video_url once rendered)
106
  "url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
107
+ "download_url": (storage.presign_get(a.storage_key, download_name=download_name(a)) if a.storage_key
108
  else content.get("video_url")),
109
  "provider": content.get("provider"),
110
  "slide_urls": [storage.presign_get(k) for k in slides],
 
113
  }
114
 
115
 
116
+ def download_name(a: Artifact) -> str:
117
+ """The file name a download is saved under: the artifact's title and its file's extension."""
118
  title = (a.content_json or {}).get("title") or a.type
119
  ext = (a.storage_key or "").rsplit(".", 1)[-1]
120
  return f"{title[:60]}.{ext}"
backend/app/services/launchpad.py CHANGED
@@ -21,7 +21,7 @@ from app.services.email import email_service
21
  from app.services.jobs import create_job
22
  from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
23
  from app.services.social.health import mark_down
24
- from app.services.social.media import media_count, media_for
25
 
26
  log = logging.getLogger("notestack.launchpad")
27
  _URL = re.compile(r"https?://[^\s)\]>]+")
@@ -60,6 +60,8 @@ def owner_email(db: Session, workspace_id) -> str | None:
60
  def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
61
  """Publish or remind now. Leaves the item posted, reminded, failed, or scheduled again for a retry."""
62
  posts = [p for p in [item.content, *(item.thread or [])] if p and p.strip()]
 
 
63
  if not posts:
64
  item.status, item.error = "failed", "Nothing to post."
65
  db.commit()
 
21
  from app.services.jobs import create_job
22
  from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
23
  from app.services.social.health import mark_down
24
+ from app.services.social.media import carries_media, media_count, media_for
25
 
26
  log = logging.getLogger("notestack.launchpad")
27
  _URL = re.compile(r"https?://[^\s)\]>]+")
 
60
  def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
61
  """Publish or remind now. Leaves the item posted, reminded, failed, or scheduled again for a retry."""
62
  posts = [p for p in [item.content, *(item.thread or [])] if p and p.strip()]
63
+ if not posts and carries_media(db.get(Artifact, item.artifact_id) if item.artifact_id else None):
64
+ posts = [""] # a video or images on their own: the post goes out without a caption
65
  if not posts:
66
  item.status, item.error = "failed", "Nothing to post."
67
  db.commit()
backend/app/services/social/media.py CHANGED
@@ -1,4 +1,5 @@
1
- """What a scheduled post carries from the artifact it was made from: images (quote cards, carousel slides) or one
 
2
  video (an audiogram, or a blog2video video), or a file the user uploaded from their computer (an `upload`: one image or
3
  one MP4). Summaries, launch kits and mind maps post as text; audio overviews can't
4
  be posted (X and LinkedIn take no audio files).
@@ -69,11 +70,18 @@ def media_count(a: Artifact) -> int:
69
  return 1
70
  if a.type == "carousel":
71
  return len((a.content_json or {}).get("slide_keys") or ([a.storage_key] if a.storage_key else []))
 
 
72
  if a.type in ("video", "upload"):
73
  return 1
74
  return 0
75
 
76
 
 
 
 
 
 
77
  def upload_kind(a: Artifact) -> str | None:
78
  """An uploaded file's kind: image | video."""
79
  return (a.content_json or {}).get("media") if a.type == "upload" else None
@@ -91,13 +99,16 @@ def check_postable(a: Artifact, platform: str) -> None:
91
  raise HTTPException(400, "Audio can't be posted: X and LinkedIn don't take audio files.")
92
  if a.type == "launch_kit": # a post is written from a kit (kit_id); the kit itself is never an attachment
93
  raise HTTPException(400, "A launch kit can't be attached. Attach a video, quote card or carousel instead.")
94
- if a.type not in TEXT_ONLY and a.type not in ("quote_card", "carousel", "video", "upload"):
95
  raise HTTPException(400, "This can't be posted.")
96
  if a.type in TEXT_ONLY:
97
  return
98
  if platform not in MEDIA_PLATFORMS:
99
  raise HTTPException(400, "Images and videos can only be posted to X and LinkedIn.")
100
- if a.type == "video" and is_blog2video(a):
 
 
 
101
  if not (a.content_json or {}).get("video_url"):
102
  raise HTTPException(400, "This video isn't finished yet. Post it once it has rendered.")
103
  elif a.status != "ready" or not a.storage_key:
@@ -134,6 +145,9 @@ def media_for(a: Artifact | None, platform: str) -> Iterator[list[Media]]:
134
  keys = (a.content_json or {}).get("slide_keys") or [a.storage_key]
135
  out = [Media("image", f"slide-{i + 1}.png", "image/png", data=storage.get_bytes(k))
136
  for i, k in enumerate(keys[:MAX_IMAGES[platform]])]
 
 
 
137
  elif a.type == "upload" and upload_kind(a) == "image":
138
  c = a.content_json or {}
139
  out = [Media("image", c.get("filename") or "image", c.get("content_type") or "image/png",
@@ -160,6 +174,21 @@ def media_for(a: Artifact | None, platform: str) -> Iterator[list[Media]]:
160
  os.unlink(path)
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def _temp_file(temps: list[str]) -> str:
164
  fd, path = tempfile.mkstemp(prefix="notestack-post-", suffix=".mp4")
165
  os.close(fd)
 
1
+ """What a scheduled post carries from the artifact it was made from: images (quote cards, carousel slides, a slide
2
+ deck's slides, drawn when the post goes out) or one
3
  video (an audiogram, or a blog2video video), or a file the user uploaded from their computer (an `upload`: one image or
4
  one MP4). Summaries, launch kits and mind maps post as text; audio overviews can't
5
  be posted (X and LinkedIn take no audio files).
 
70
  return 1
71
  if a.type == "carousel":
72
  return len((a.content_json or {}).get("slide_keys") or ([a.storage_key] if a.storage_key else []))
73
+ if a.type == "slide_deck":
74
+ return len(((a.content_json or {}).get("deck") or {}).get("slides") or [])
75
  if a.type in ("video", "upload"):
76
  return 1
77
  return 0
78
 
79
 
80
+ def carries_media(a: Artifact | None) -> bool:
81
+ """Whether a post of this artifact carries images or a video: such a post may go out without a caption."""
82
+ return a is not None and a.type not in TEXT_ONLY and media_count(a) > 0
83
+
84
+
85
  def upload_kind(a: Artifact) -> str | None:
86
  """An uploaded file's kind: image | video."""
87
  return (a.content_json or {}).get("media") if a.type == "upload" else None
 
99
  raise HTTPException(400, "Audio can't be posted: X and LinkedIn don't take audio files.")
100
  if a.type == "launch_kit": # a post is written from a kit (kit_id); the kit itself is never an attachment
101
  raise HTTPException(400, "A launch kit can't be attached. Attach a video, quote card or carousel instead.")
102
+ if a.type not in TEXT_ONLY and a.type not in ("quote_card", "carousel", "video", "upload", "slide_deck"):
103
  raise HTTPException(400, "This can't be posted.")
104
  if a.type in TEXT_ONLY:
105
  return
106
  if platform not in MEDIA_PLATFORMS:
107
  raise HTTPException(400, "Images and videos can only be posted to X and LinkedIn.")
108
+ if a.type == "slide_deck": # drawn from the deck when the post goes out: nothing stored to wait for
109
+ if a.status != "ready" or not media_count(a):
110
+ raise HTTPException(400, "This slide deck isn't ready yet. Post it once it has finished.")
111
+ elif a.type == "video" and is_blog2video(a):
112
  if not (a.content_json or {}).get("video_url"):
113
  raise HTTPException(400, "This video isn't finished yet. Post it once it has rendered.")
114
  elif a.status != "ready" or not a.storage_key:
 
145
  keys = (a.content_json or {}).get("slide_keys") or [a.storage_key]
146
  out = [Media("image", f"slide-{i + 1}.png", "image/png", data=storage.get_bytes(k))
147
  for i, k in enumerate(keys[:MAX_IMAGES[platform]])]
148
+ elif a.type == "slide_deck":
149
+ out = [Media("image", f"slide-{i + 1}.png", "image/png", data=png)
150
+ for i, png in enumerate(_slide_images(a, MAX_IMAGES[platform]))]
151
  elif a.type == "upload" and upload_kind(a) == "image":
152
  c = a.content_json or {}
153
  out = [Media("image", c.get("filename") or "image", c.get("content_type") or "image/png",
 
174
  os.unlink(path)
175
 
176
 
177
+ def _slide_images(a: Artifact, limit: int) -> list[bytes]:
178
+ """A slide deck's first `limit` slides as PNGs, exactly as the deck shows them."""
179
+ from app.infographics.image import ImageUnavailable
180
+ from app.slides import export
181
+ from app.slides.build import stored
182
+
183
+ got = stored(a.content_json or {})
184
+ if not got:
185
+ raise SocialError("This slide deck has no slides to post.", permanent=True)
186
+ try:
187
+ return export.render_slide_pngs(*got, limit=limit)
188
+ except ImageUnavailable as exc: # no Chrome on this worker right now: worth another try
189
+ raise SocialError(f"The slides could not be drawn: {exc}") from exc
190
+
191
+
192
  def _temp_file(temps: list[str]) -> str:
193
  fd, path = tempfile.mkstemp(prefix="notestack-post-", suffix=".mp4")
194
  os.close(fd)
backend/app/services/social/x.py CHANGED
@@ -157,7 +157,7 @@ def publish(db: Session, account: SocialAccount, posts: list[str], media: list |
157
  media_ids = [(_upload_video if m.kind == "video" else _upload_image)(headers, m) for m in media]
158
  first_id, reply_to = None, None
159
  for text in posts:
160
- body: dict = {"text": text}
161
  if reply_to:
162
  body["reply"] = {"in_reply_to_tweet_id": reply_to}
163
  elif media_ids:
 
157
  media_ids = [(_upload_video if m.kind == "video" else _upload_image)(headers, m) for m in media]
158
  first_id, reply_to = None, None
159
  for text in posts:
160
+ body: dict = {"text": text} if text else {} # X takes a post with media and no text, but not an empty text
161
  if reply_to:
162
  body["reply"] = {"in_reply_to_tweet_id": reply_to}
163
  elif media_ids:
backend/app/slides/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Slide decks: the model writes a deck as JSON (content.py clamps it to a fixed shape), layout.py places every slide
2
+ on a 1920 x 1080 canvas, and the same placed elements are drawn as HTML (render.py: preview, presenting, PDF) and as
3
+ an editable PowerPoint (export.py). The model never writes markup or picks positions, colours or sizes."""
backend/app/slides/build.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """From a written deck to what is stored and shown. `finish` picks each slide's variant, fits its text, and checks
2
+ the result in Chrome when one is installed. `restructure` takes a deck back from the editor and fits it again.
3
+ `view` reads a stored deck back (clamped again, so older or edited decks still draw) and gives its pages; one slide
4
+ that cannot be drawn becomes a plain one instead of breaking the deck."""
5
+
6
+ import copy
7
+ import logging
8
+ import random
9
+ from collections import Counter
10
+
11
+ from app.infographics.image import ImageUnavailable
12
+ from app.slides import export
13
+ from app.slides.content import clamp_deck, clamp_slide, empty_slide
14
+ from app.slides.layout import GEOMETRY, SCALES, VARIANTS, assign_variants, compose, fits_variant, seed_of
15
+ from app.slides.render import slide_html
16
+ from app.slides.themes import theme_id
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def fit(deck: dict, theme: str, fmt: str, seed: int) -> dict:
22
+ """Every slide as it will be drawn: text shortened where it had to be, so what is stored is what is shown."""
23
+ out = copy.deepcopy(deck)
24
+ out["slides"] = [compose(out, i, theme, fmt, seed).slide for i in range(len(out["slides"]))]
25
+ return out
26
+
27
+
28
+ def finish(deck: dict, theme: str, fmt: str, seed: int, previous: list[tuple[str, str]] | None = None) -> dict:
29
+ """Variants, then refit. Raises ValueError when a slide cannot be drawn at all."""
30
+ theme = theme_id(theme)
31
+ deck = copy.deepcopy(deck)
32
+ assign_variants(deck["slides"], theme, seed, previous)
33
+ return refit(deck, theme, fmt, seed)
34
+
35
+
36
+ def refit(deck: dict, theme: str, fmt: str, seed: int) -> dict:
37
+ """Fitted text and a Chrome check that no text box overflows (slides that do are drawn a step smaller and fitted
38
+ again), keeping each slide's variant. Raises ValueError when a slide cannot be drawn at all."""
39
+ theme = theme_id(theme)
40
+ deck = fit(deck, theme, fmt, seed)
41
+ for _ in range(3):
42
+ try:
43
+ bad = export.overflowing(deck, theme, fmt, seed)
44
+ except ImageUnavailable:
45
+ break # no Chrome here: the measured fit alone keeps text inside its boxes
46
+ if not bad:
47
+ break
48
+ log.info("slides %s overflow in Chrome, drawing them smaller", sorted(bad))
49
+ for i in bad:
50
+ deck["slides"][i]["shrink"] = min(int(deck["slides"][i].get("shrink") or 0) + 1, len(SCALES) - 1)
51
+ deck = fit(deck, theme, fmt, seed)
52
+ for i in range(len(deck["slides"])):
53
+ slide_html(deck, i, theme, fmt, seed) # raises if a slide cannot be drawn: better now than when shown
54
+ return deck
55
+
56
+
57
+ class BadEdit(ValueError):
58
+ """A deck sent back from the editor that cannot be kept (no slides, or too many)."""
59
+
60
+
61
+ MAX_SLIDES = 30
62
+
63
+
64
+ def fill_variants(slides: list[dict], theme: str, seed: int) -> None:
65
+ """Give a variant, in place, to each slide that has none or whose variant no longer suits it (a new slide, a changed
66
+ layout, a card row now with five points). Every other slide keeps its own, so editing one slide never redraws the
67
+ rest. The choice follows assign_variants: suited to the content, not the same as a neighbour, unused ones first."""
68
+ theme = theme_id(theme)
69
+ def ok(i: int) -> bool:
70
+ s = slides[i]
71
+ return s["variant"] in VARIANTS[s["layout"]] and fits_variant(s, theme, s["variant"])
72
+ keep = [ok(i) for i in range(len(slides))]
73
+ for i, s in enumerate(slides):
74
+ before = (slides[i - 1]["layout"], slides[i - 1]["variant"]) if i else None
75
+ if keep[i] and (s["layout"], s["variant"]) != before:
76
+ continue
77
+ after = (slides[i + 1]["layout"], slides[i + 1]["variant"]) if i + 1 < len(slides) and keep[i + 1] else None
78
+ options = [v for v in VARIANTS[s["layout"]] if fits_variant(s, theme, v)] or list(VARIANTS[s["layout"]])
79
+ fresh = [v for v in options if (s["layout"], v) not in (before, after)] or options
80
+ used = Counter((x["layout"], x["variant"]) for j, x in enumerate(slides) if j != i)
81
+ rnd = random.Random(seed + i)
82
+ s["variant"] = min(fresh, key=lambda v: (used[(s["layout"], v)], rnd.random()))
83
+ keep[i] = True
84
+
85
+
86
+ def restructure(content: dict, slides: object, final: bool) -> dict:
87
+ """The stored deck with the slides the editor sent (text edited, slides added, removed, moved, or changed to another
88
+ layout). They are coerced like model output (clamp_deck: plain text, the format's limits, valid layouts), slides
89
+ that need one get a variant, and the deck is fitted: quickly for a preview, with the Chrome check when `final`."""
90
+ got = stored(content)
91
+ if not got:
92
+ raise BadEdit("This deck has no slides to edit.")
93
+ deck, theme, fmt, seed = got
94
+ if not isinstance(slides, list) or not slides:
95
+ raise BadEdit("A deck needs at least one slide.")
96
+ if len(slides) > MAX_SLIDES:
97
+ raise BadEdit(f"A deck can have at most {MAX_SLIDES} slides.")
98
+ new = clamp_deck({"title": deck["title"], "subtitle": deck["subtitle"], "slides": slides}, fmt)
99
+ if not new:
100
+ raise BadEdit("A deck needs at least one slide with a heading.")
101
+ for s in new["slides"]:
102
+ s["shrink"] = 0 # text changed: fitted afresh, so shorter text can be drawn larger again
103
+ fill_variants(new["slides"], theme, seed)
104
+ return refit(new, theme, fmt, seed) if final else fit(new, theme, fmt, seed)
105
+
106
+
107
+ def slots(content: dict) -> list[list[str]]:
108
+ """For each slide, the parts its variant has a place for (kicker, lead, body, by, closing...): what the editor
109
+ can offer to add."""
110
+ got = stored(content)
111
+ if not got:
112
+ return []
113
+ deck, theme, _fmt, _seed = got
114
+ out = []
115
+ for s in deck["slides"]:
116
+ variants = GEOMETRY[theme][s["layout"]]
117
+ out.append(sorted(k for k, v in (variants.get(s["variant"]) or next(iter(variants.values()))).items() if v))
118
+ return out
119
+
120
+
121
+ def stored(content: dict) -> tuple[dict, str, str, int] | None:
122
+ """A stored deck, clamped, with its theme, format and seed (None when there is none)."""
123
+ fmt = content.get("format") if content.get("format") in ("detailed", "presenter") else "detailed"
124
+ deck = clamp_deck(content.get("deck") or {}, fmt)
125
+ if not deck:
126
+ return None
127
+ return deck, theme_id(content.get("theme")), fmt, seed_of(content.get("seed"))
128
+
129
+
130
+ def _placeholder(deck: dict, index: int) -> dict:
131
+ s = empty_slide("section")
132
+ s["heading"] = deck.get("title") or "Slide"
133
+ return clamp_slide(s, "detailed")
134
+
135
+
136
+ def view(content: dict) -> list[str]:
137
+ """Each slide of a stored deck as a standalone page."""
138
+ got = stored(content)
139
+ if not got:
140
+ return []
141
+ deck, theme, fmt, seed = got
142
+ pages = []
143
+ for i in range(len(deck["slides"])):
144
+ try:
145
+ pages.append(slide_html(deck, i, theme, fmt, seed))
146
+ except Exception:
147
+ log.exception("slide %s could not be drawn", i)
148
+ safe = copy.deepcopy(deck)
149
+ safe["slides"][i] = _placeholder(deck, i)
150
+ pages.append(slide_html(safe, i, theme, fmt, seed))
151
+ return pages
backend/app/slides/content.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What a deck says, in one fixed shape. Model output is never trusted: every slide is coerced to the same keys (empty
2
+ defaults for what it lacks), stripped to plain text, cut to the format's limits, and moved to a simpler layout when
3
+ it lacks what its own layout needs. Whatever goes in, what comes out can always be drawn."""
4
+
5
+ import re
6
+
7
+ LAYOUTS = ("title", "agenda", "section", "points", "two_column", "stat", "quote", "closing")
8
+ FORMATS = ("detailed", "presenter")
9
+ LENGTHS = {"short": (6, 8), "default": (9, 11), "long": (12, 16)} # slides in the deck, opening and closing included
10
+
11
+ # Characters per field. Detailed slides explain; presenter slides only cue the speaker.
12
+ LIMITS = {
13
+ "detailed": {"title": 80, "subtitle": 170, "kicker": 32, "heading": 90, "lead": 240, "term": 44, "text": 220,
14
+ "points": (2, 5), "label": 40, "item": 120, "items": 4, "value": 16, "stat_label": 120,
15
+ "quote": 260, "by": 70, "takeaway": 150, "takeaways": 3, "closing": 140, "agenda": 64,
16
+ "agenda_items": 14, "notes": 1500},
17
+ "presenter": {"title": 70, "subtitle": 130, "kicker": 32, "heading": 64, "lead": 130, "term": 36, "text": 80,
18
+ "points": (2, 4), "label": 32, "item": 70, "items": 4, "value": 16, "stat_label": 90,
19
+ "quote": 200, "by": 70, "takeaway": 90, "takeaways": 3, "closing": 110, "agenda": 56,
20
+ "agenda_items": 14, "notes": 1500},
21
+ }
22
+
23
+ _TAGS = re.compile(r"<[^>]*>")
24
+ _CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
25
+ _MD_EMPH = re.compile(r"(\*\*|__|\*|`|~~)")
26
+ _MD_LEAD = re.compile(r"^\s*(?:#{1,6}\s+|[-*+•]\s+|\d{1,2}[.)]\s+|>\s*)")
27
+ _MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
28
+ _CITE = re.compile(r"\s*\[\d+\]")
29
+ _SPACE = re.compile(r"\s+")
30
+
31
+
32
+ def plain(value: object) -> str:
33
+ """One line of plain text: no markup, markdown, citation markers, control characters or em dashes."""
34
+ if value is None or isinstance(value, dict | list | tuple | set):
35
+ return ""
36
+ s = _CTRL.sub(" ", str(value))
37
+ s = _MD_LINK.sub(r"\1", _TAGS.sub(" ", s))
38
+ s = _MD_LEAD.sub("", s)
39
+ s = _CITE.sub("", _MD_EMPH.sub("", s))
40
+ s = s.replace("—", ", ").replace("–", "-").replace("&nbsp;", " ")
41
+ s = _SPACE.sub(" ", s).strip()
42
+ return s.replace(" ,", ",").strip(" ,;")
43
+
44
+
45
+ def clip(value: object, limit: int) -> str:
46
+ """Plain text of at most `limit` characters. A longer text ends at its last full sentence when one fits well,
47
+ otherwise at a word, with an ellipsis."""
48
+ s = plain(value)
49
+ if len(s) <= limit:
50
+ return s
51
+ head = s[:limit]
52
+ end = max(head.rfind(". "), head.rfind("! "), head.rfind("? "))
53
+ if end >= limit * 0.55:
54
+ return head[: end + 1]
55
+ # No sentence ends in reach: end at the last clause instead (", ensuring..." dropped), as a full stop.
56
+ clause = max(head.rfind(", "), head.rfind("; "), head.rfind(" and "), head.rfind(" while "), head.rfind(" which "))
57
+ if clause >= limit * 0.6:
58
+ return head[:clause].rstrip(" ,;:-") + "."
59
+ cut = head[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:.-") or head[: limit - 1]
60
+ return cut + "…"
61
+
62
+
63
+ def _dict(value: object) -> dict:
64
+ if hasattr(value, "model_dump"):
65
+ value = value.model_dump()
66
+ return value if isinstance(value, dict) else {}
67
+
68
+
69
+ def _list(value: object) -> list:
70
+ return list(value) if isinstance(value, list | tuple) else []
71
+
72
+
73
+ def _sources(raw: object) -> list[dict]:
74
+ out = []
75
+ for r in _list(raw):
76
+ r = _dict(r)
77
+ try:
78
+ start, end = int(r["line_start"]), int(r["line_end"])
79
+ except (KeyError, TypeError, ValueError):
80
+ continue
81
+ path = plain(r.get("path"))
82
+ if not path:
83
+ continue
84
+ item = {"path": path, "line_start": start, "line_end": max(start, end)}
85
+ for key in ("title", "document_id"):
86
+ if r.get(key):
87
+ item[key] = plain(r[key])[:200]
88
+ if r.get("quote"):
89
+ item["quote"] = clip(r["quote"], 400)
90
+ out.append(item)
91
+ return out[:8]
92
+
93
+
94
+ def _points(raw: object, lim: dict) -> list[dict]:
95
+ out = []
96
+ for p in _list(raw):
97
+ if isinstance(p, str):
98
+ p = {"term": "", "text": p}
99
+ p = _dict(p)
100
+ term, text = clip(p.get("term"), lim["term"]), clip(p.get("text"), lim["text"])
101
+ if not text and term: # a bare term is the point itself
102
+ term, text = "", clip(p.get("term"), lim["text"])
103
+ if text:
104
+ out.append({"term": term, "text": text})
105
+ return out
106
+
107
+
108
+ def _column(raw: object, lim: dict) -> dict:
109
+ c = _dict(raw)
110
+ items = [t for t in (clip(i, lim["item"]) for i in _list(c.get("items"))) if t][: lim["items"]]
111
+ return {"label": clip(c.get("label"), lim["label"]), "items": items}
112
+
113
+
114
+ _LIMIT_KEY = {"kicker": "kicker", "heading": "heading", "lead": "lead", "closing": "closing", "stat.value": "value",
115
+ "stat.label": "stat_label", "quote.text": "quote", "quote.by": "by", "left.label": "label",
116
+ "right.label": "label", "left.items": "item", "right.items": "item", "points.term": "term",
117
+ "points.text": "text", "takeaways": "takeaway"}
118
+
119
+
120
+ def field_limit(layout: str, field: str, fmt: str) -> int:
121
+ """How many characters `field` may hold on a slide of this layout: what clamp_slide cuts it to."""
122
+ lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
123
+ key = re.sub(r"\.\d+", "", field) # "points.2.text" -> "points.text"
124
+ if layout == "title" and key in ("heading", "lead"):
125
+ return lim["title" if key == "heading" else "subtitle"]
126
+ if layout == "agenda" and key == "points.text":
127
+ return lim["agenda"]
128
+ return lim[_LIMIT_KEY[key]]
129
+
130
+
131
+ def empty_slide(layout: str = "points") -> dict:
132
+ return {"layout": layout, "variant": "", "kicker": "", "heading": "", "lead": "", "points": [],
133
+ "left": {"label": "", "items": []}, "right": {"label": "", "items": []},
134
+ "stat": {"value": "", "label": ""}, "quote": {"text": "", "by": ""}, "takeaways": [], "closing": "",
135
+ "notes": "", "sources": [], "shrink": 0}
136
+
137
+
138
+ def clamp_slide(raw: object, fmt: str) -> dict | None:
139
+ """One slide in the fixed shape, or None when nothing on it can be shown."""
140
+ lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
141
+ r = _dict(raw)
142
+ s = empty_slide(r.get("layout") if r.get("layout") in LAYOUTS else "points")
143
+ s["variant"] = plain(r.get("variant"))[:4].lower()
144
+ s["kicker"] = clip(r.get("kicker"), lim["kicker"])
145
+ s["heading"] = clip(r.get("heading"), lim["title"] if s["layout"] == "title" else lim["heading"])
146
+ s["lead"] = clip(r.get("lead"), lim["subtitle"] if s["layout"] == "title" else lim["lead"])
147
+ lo, hi = lim["points"]
148
+ points = _points(r.get("points"), lim)
149
+ if s["layout"] == "agenda":
150
+ s["points"] = [{"term": "", "text": clip(p["text"], lim["agenda"])} for p in points][: lim["agenda_items"]]
151
+ else:
152
+ s["points"] = points[:hi]
153
+ s["left"], s["right"] = _column(r.get("left"), lim), _column(r.get("right"), lim)
154
+ stat = _dict(r.get("stat"))
155
+ s["stat"] = {"value": clip(stat.get("value"), lim["value"]), "label": clip(stat.get("label"), lim["stat_label"])}
156
+ quote = _dict(r.get("quote"))
157
+ s["quote"] = {"text": clip(quote.get("text"), lim["quote"]).strip("\"'“”"), "by": clip(quote.get("by"), lim["by"])}
158
+ s["takeaways"] = [t for t in (clip(t, lim["takeaway"]) for t in _list(r.get("takeaways"))) if t][: lim["takeaways"]]
159
+ s["closing"] = clip(r.get("closing"), lim["closing"])
160
+ s["notes"] = clip(r.get("notes"), lim["notes"])
161
+ s["sources"] = _sources(r.get("sources"))
162
+ try:
163
+ s["shrink"] = max(0, min(int(r.get("shrink") or 0), 4))
164
+ except (TypeError, ValueError):
165
+ s["shrink"] = 0
166
+
167
+ # A layout missing what it needs becomes a simpler one, so the drawing never meets an empty slot.
168
+ lay = s["layout"]
169
+ if lay == "two_column" and not (s["left"]["items"] and s["right"]["items"]):
170
+ merged = [{"term": "", "text": t} for t in s["left"]["items"] + s["right"]["items"]]
171
+ s["points"] = s["points"] or merged[:hi]
172
+ lay = "points"
173
+ if lay == "stat" and not (s["stat"]["value"] and s["stat"]["label"]):
174
+ lay = "points"
175
+ if lay == "quote" and not s["quote"]["text"]:
176
+ lay = "points"
177
+ if lay == "agenda" and len(s["points"]) < 2:
178
+ lay = "section"
179
+ if lay == "closing" and not s["takeaways"] and not s["closing"]:
180
+ lay = "section"
181
+ if lay == "points" and len(s["points"]) < lo:
182
+ if s["points"] and not s["lead"]: # one point: say it as the slide's lead
183
+ s["lead"] = clip(s["points"][0]["text"], lim["lead"])
184
+ s["points"] = []
185
+ lay = "section"
186
+ if lay != s["layout"]:
187
+ s["variant"] = ""
188
+ s["layout"] = lay
189
+ if not s["heading"]:
190
+ s["heading"] = s["kicker"] or (s["closing"] if lay == "closing" else "")
191
+ if not s["heading"] and lay not in ("quote", "closing"):
192
+ return None
193
+ return s
194
+
195
+
196
+ def clamp_deck(raw: object, fmt: str) -> dict | None:
197
+ """The deck in the fixed shape, or None when it has no slide that can be shown."""
198
+ r = _dict(raw)
199
+ lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
200
+ slides = [s for s in (clamp_slide(x, fmt) for x in _list(r.get("slides"))) if s]
201
+ if not slides:
202
+ return None
203
+ title = clip(r.get("title"), lim["title"]) or (slides[0]["heading"] if slides[0]["layout"] == "title" else "") or "Slide deck"
204
+ return {"title": title, "subtitle": clip(r.get("subtitle"), lim["subtitle"]), "slides": slides}
backend/app/slides/decor.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The drawing behind a slide's text: stars, glows, planets, orbit rings and constellations. Each layout variant puts
2
+ its drawing where its text is not, and the deck's seed moves it a little, so two decks never look alike while one deck
3
+ always looks the same. The same list is drawn as SVG for the page and as a picture for the PowerPoint."""
4
+
5
+ import hashlib
6
+ import math
7
+ import random
8
+ from dataclasses import dataclass, field
9
+
10
+ from app.slides.themes import H, W
11
+
12
+
13
+ @dataclass
14
+ class Shape:
15
+ kind: str # glow | planet | ring | band | const | stars
16
+ cx: float = 0
17
+ cy: float = 0
18
+ r: float = 0
19
+ rx: float = 0
20
+ ry: float = 0
21
+ rot: float = 0
22
+ color: str = "accent" # palette key
23
+ opacity: float = 1.0
24
+ width: float = 2.0
25
+ points: list[tuple[float, float, float, float]] = field(default_factory=list) # stars: x, y, r, opacity
26
+ edges: list[tuple[int, int]] = field(default_factory=list)
27
+ # ring, band: only this part of the path, in degrees round it (0 to 180 is the half nearer the viewer, below the
28
+ # centre before the tilt). None: all of it.
29
+ arc: tuple[float, float] | None = None
30
+ inner: float = 0 # band: the inner edge, as a share of rx and ry (the band fills from there out to the ring)
31
+
32
+
33
+ @dataclass
34
+ class Decor:
35
+ hot: tuple[float, float] # where the sky is brightest
36
+ shapes: list[Shape]
37
+
38
+
39
+ # Where a variant has room for a drawing: (x0, y0, x1, y1). None: only stars and a soft glow.
40
+ FREE = {
41
+ "dark-space": {
42
+ ("points", "a"): (1520, 140, 1900, 940), ("points", "c"): None, ("points", "b"): None, ("points", "d"): None,
43
+ ("agenda", "a"): (120, 660, 820, 1000), ("agenda", "b"): None,
44
+ ("two_column", "a"): None, ("two_column", "b"): None,
45
+ ("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (140, 60, 760, 140),
46
+ ("section", "b"): (1500, 60, 1900, 560), ("closing", "b"): None, ("closing", "c"): (140, 860, 960, 1060),
47
+ },
48
+ "light-space": {
49
+ ("points", "a"): None, ("points", "b"): None, ("points", "c"): None, ("points", "d"): None,
50
+ ("agenda", "a"): None, ("agenda", "b"): (120, 760, 820, 1000),
51
+ ("two_column", "a"): None, ("two_column", "b"): None,
52
+ ("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (100, 360, 400, 760),
53
+ ("closing", "a"): None, ("closing", "b"): None, ("closing", "c"): None,
54
+ },
55
+ }
56
+
57
+
58
+ def _stars(rnd: random.Random, dark: bool) -> Shape:
59
+ n = 120 if dark else 70
60
+ pts = []
61
+ for _ in range(n):
62
+ if dark:
63
+ r, op = rnd.choice((0.8, 1.0, 1.2, 1.2, 1.6, 2.2)), round(rnd.uniform(0.18, 0.85), 2)
64
+ else:
65
+ r, op = rnd.choice((0.9, 1.2, 1.5, 2.0)), round(rnd.uniform(0.08, 0.26), 2)
66
+ pts.append((rnd.uniform(0, W), rnd.uniform(0, H), r, op))
67
+ return Shape("stars", points=pts, color="star")
68
+
69
+
70
+ def _constellation(rnd: random.Random, box: tuple[float, float, float, float], n: int = 7) -> Shape:
71
+ x0, y0, x1, y1 = box
72
+ pts = [(rnd.uniform(x0, x1), rnd.uniform(y0, y1), rnd.choice((4.0, 5.0, 6.0, 8.0)), 1.0) for _ in range(n)]
73
+ pts.sort(key=lambda p: p[0])
74
+ edges = [(i, i + 1) for i in range(n - 1)]
75
+ if n > 4:
76
+ edges.append((rnd.randrange(0, n - 3), rnd.randrange(n - 2, n)))
77
+ return Shape("const", points=pts, edges=edges, color="accent", opacity=0.85)
78
+
79
+
80
+ def on_ring(ring: Shape, degrees: float) -> tuple[float, float]:
81
+ """The point at `degrees` round a ring's path, after its tilt: where a planet sits so it is on the drawn line.
82
+ Same maths as the renderers (SVG rotate(rot cx cy), the PowerPoint's polyline)."""
83
+ a, rot = math.radians(degrees), math.radians(ring.rot)
84
+ x, y = ring.rx * math.cos(a), ring.ry * math.sin(a)
85
+ return ring.cx + x * math.cos(rot) - y * math.sin(rot), ring.cy + x * math.sin(rot) + y * math.cos(rot)
86
+
87
+
88
+ def ring_path(s: Shape, scale: float = 1.0, steps: int = 180) -> list[tuple[float, float]]:
89
+ """Points along a ring's (scaled) path, over its arc, for the renderers to draw."""
90
+ a0, a1 = s.arc or (0, 360)
91
+ scaled = Shape("ring", s.cx, s.cy, rx=s.rx * scale, ry=s.ry * scale, rot=s.rot)
92
+ return [on_ring(scaled, a0 + (a1 - a0) * k / steps) for k in range(steps + 1)]
93
+
94
+
95
+ # A ringed planet's band, from the inside out: (inner, outer, opacity) as shares of the ring. A brighter inner ring
96
+ # and a fainter outer one with a dark gap between, as Saturn's are.
97
+ BANDS = ((0.72, 0.86, 0.34), (0.89, 1.0, 0.22))
98
+ NEAR, FAR = (0, 180), (180, 360)
99
+
100
+
101
+ def ringed_planet(cx: float, cy: float, r: float, rx: float, ry: float, rot: float, opacity: float = 1.0) -> list[Shape]:
102
+ """A planet inside its rings: the far half of the rings goes behind the planet and the near half in front, so the
103
+ rings wrap round it instead of lying flat over its face. The far half is a little dimmer, being further away."""
104
+ def half(arc: tuple[float, float], op: float) -> list[Shape]:
105
+ out = [Shape("band", cx, cy, rx=rx * hi, ry=ry * hi, rot=rot, color="accent", opacity=o * op, arc=arc, inner=lo / hi)
106
+ for lo, hi, o in BANDS]
107
+ out.append(Shape("ring", cx, cy, rx=rx, ry=ry, rot=rot, color="accent", opacity=0.8 * op, width=2, arc=arc))
108
+ return out
109
+ return [*half(FAR, 0.6 * opacity), Shape("planet", cx, cy, r), *half(NEAR, opacity)]
110
+
111
+
112
+ def build(theme: str, layout: str, variant: str, seed: int, index: int) -> Decor:
113
+ """The drawing for one slide."""
114
+ dark = theme == "dark-space"
115
+ rnd = random.Random(int(hashlib.sha1(f"{seed}:{index}:{layout}:{variant}".encode()).hexdigest()[:12], 16))
116
+ j = lambda span: rnd.uniform(-span, span) # noqa: E731 (a small, seeded nudge)
117
+ shapes = [_stars(rnd, dark)]
118
+ hot = (W * rnd.uniform(0.25, 0.75), H * rnd.uniform(0.15, 0.5))
119
+ key = (layout, variant)
120
+
121
+ if dark:
122
+ if key == ("title", "a"):
123
+ hot = (960, 760)
124
+ shapes += [Shape("glow", 960, 900, 900, color="accent2", opacity=0.55),
125
+ Shape("planet", 960 + j(60), 1080 + 560 + j(40), 820),
126
+ Shape("ring", 960, 860 + j(20), rx=1150, ry=150, rot=-4 + j(3), color="accent", opacity=0.45, width=2.5)]
127
+ elif key == ("title", "b"):
128
+ cx, cy = 1520 + j(60), 300 + j(50)
129
+ hot = (cx, cy)
130
+ shapes += [Shape("glow", cx, cy, 520, color="accent2", opacity=0.5),
131
+ *ringed_planet(cx, cy, 230 + j(25), rx=395, ry=82, rot=-18 + j(6))]
132
+ elif key == ("title", "c"):
133
+ hot = (1450, 540)
134
+ shapes += [Shape("glow", 1450, 540, 620, color="accent2", opacity=0.35),
135
+ _constellation(rnd, (1120, 170, 1800, 910), 8)]
136
+ elif layout == "section" and variant == "a":
137
+ hot = (960, 540)
138
+ shapes += [Shape("glow", 960, 540, 760, color="accent2", opacity=0.35),
139
+ Shape("ring", 960, 540 + j(20), rx=980, ry=260, rot=-8 + j(5), color="accent", opacity=0.3, width=2)]
140
+ elif layout == "closing" and variant == "a":
141
+ hot = (960, 980)
142
+ shapes += [Shape("glow", 960, 1120, 760, color="accent2", opacity=0.5),
143
+ Shape("ring", 960, 1210, rx=1080, ry=880, rot=0, color="accent", opacity=0.35, width=2.5)]
144
+ else:
145
+ corner = rnd.choice(((0, 0), (W, 0), (0, H), (W, H)))
146
+ shapes.append(Shape("glow", corner[0], corner[1], 700, color="accent2", opacity=0.32))
147
+ hot = (corner[0] * 0.8 + W * 0.1, corner[1] * 0.8 + H * 0.1)
148
+ free = FREE["dark-space"].get(key)
149
+ if free:
150
+ x0, y0, x1, y1 = free
151
+ r = min(x1 - x0, y1 - y0) * rnd.uniform(0.26, 0.34)
152
+ cx, cy = rnd.uniform(x0 + r, x1 - r), rnd.uniform(y0 + r, y1 - r)
153
+ shapes += [Shape("glow", cx, cy, r * 2.2, color="accent2", opacity=0.35),
154
+ *ringed_planet(cx, cy, r, rx=r * 1.8, ry=r * 0.38, rot=-16 + j(10), opacity=0.9)]
155
+ else:
156
+ ring = lambda cx, cy, r, op=0.3: Shape("ring", cx, cy, rx=r, ry=r, color="accent2", opacity=op, width=2) # noqa: E731
157
+ if key == ("title", "a"):
158
+ cx, cy = 2010 + j(40), 540 + j(60)
159
+ hot = (1500, 540)
160
+ orbit = ring(cx, cy, 540, 0.24)
161
+ shapes += [ring(cx, cy, 360), orbit, ring(cx, cy, 740, 0.18), Shape("planet", *on_ring(orbit, 200 + j(20)), 34)]
162
+ elif key == ("title", "b"):
163
+ hot = (1500, 820)
164
+ shapes += [Shape("glow", 1560, 840, 520, color="accent", opacity=0.12),
165
+ _constellation(rnd, (1360, 660, 1800, 980), 7)]
166
+ elif key == ("title", "c"):
167
+ hot = (960, 540)
168
+ rot = -10 + j(4) # one tilt for both, so the rings stay concentric
169
+ orbit = Shape("ring", 960, 520, rx=900, ry=330, rot=rot, color="accent2", opacity=0.28, width=2.5)
170
+ shapes += [orbit, Shape("ring", 960, 520, rx=1040, ry=400, rot=rot, color="accent2", opacity=0.14, width=2),
171
+ Shape("planet", *on_ring(orbit, rnd.choice((20, 160, 200, 340)) + j(10)), 30)]
172
+ elif key in (("section", "a"), ("section", "b")):
173
+ cx, toward = (1960, 180) if variant == "a" else (-40, 0) # the planet sits on the side facing the text
174
+ orbit = ring(cx, 560, 460, 0.22)
175
+ shapes += [ring(cx, 560, 300), orbit, ring(cx, 560, 640, 0.15), Shape("planet", *on_ring(orbit, toward + j(10)), 26)]
176
+ elif key == ("closing", "c"):
177
+ hot = (960, 860)
178
+ shapes += [Shape("ring", 960, 540, rx=1000, ry=470, rot=6 + j(4), color="accent2", opacity=0.2, width=2.5)]
179
+ else:
180
+ corner = rnd.choice(((W + 60, -60), (W + 60, H + 60), (-60, H + 60)))
181
+ shapes += [ring(corner[0], corner[1], 260, 0.22), ring(corner[0], corner[1], 400, 0.15)]
182
+ free = FREE["light-space"].get(key)
183
+ if free:
184
+ shapes.append(_constellation(rnd, free, 5))
185
+ return Decor(hot, shapes)
backend/app/slides/export.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Files made from a deck: a PDF (the deck's own HTML printed by a headless Chrome, so it matches the preview) and an
2
+ editable PowerPoint (the same placed elements as native text boxes and shapes over a drawn background). Also the
3
+ overflow check: Chrome lays the slides out and reports any text box whose text is taller than the box."""
4
+
5
+ import hashlib
6
+ import io
7
+ import json
8
+ import math
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+ from collections import OrderedDict
13
+ from pathlib import Path
14
+ from threading import Lock
15
+
16
+ from PIL import Image, ImageDraw
17
+ from pptx import Presentation
18
+ from pptx.dml.color import RGBColor
19
+ from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
20
+ from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN
21
+ from pptx.oxml.ns import qn
22
+ from pptx.util import Emu, Pt
23
+
24
+ from app.infographics.image import ImageUnavailable, chrome
25
+ from app.slides.decor import Decor, Shape, build, ring_path
26
+ from app.slides.layout import El, compose
27
+ from app.slides.render import SHADE, color, deck_print_html, planet_colors, ring_shade, slides_html
28
+ from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
29
+
30
+ TIMEOUT = 120
31
+ KEEP = 6
32
+ EMU_PX = 6350 # 1920 px = 13.333 in: one pixel is half a point
33
+
34
+ _cache: OrderedDict[str, bytes] = OrderedDict()
35
+ _lock = Lock()
36
+
37
+
38
+ def _cached(key: str, make) -> bytes:
39
+ with _lock:
40
+ if key in _cache:
41
+ _cache.move_to_end(key)
42
+ return _cache[key]
43
+ data = make()
44
+ with _lock:
45
+ _cache[key] = data
46
+ while len(_cache) > KEEP:
47
+ _cache.popitem(last=False)
48
+ return data
49
+
50
+
51
+ def _key(kind: str, deck: dict, theme: str, fmt: str, seed: int) -> str:
52
+ blob = json.dumps([kind, deck, theme, fmt, seed], sort_keys=True, default=str)
53
+ return hashlib.sha256(blob.encode()).hexdigest()
54
+
55
+
56
+ def _run_chrome(html: str, args: list[str], out_name: str | None) -> tuple[bytes, str]:
57
+ binary = chrome()
58
+ if not binary:
59
+ raise ImageUnavailable("No Chrome is installed here to draw the slides.")
60
+ with tempfile.TemporaryDirectory() as tmp:
61
+ src = Path(tmp) / "deck.html"
62
+ src.write_text(html, encoding="utf-8")
63
+ out = Path(tmp) / out_name if out_name else None
64
+ cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
65
+ f"--user-data-dir={tmp}/profile", "--virtual-time-budget=10000", "--run-all-compositor-stages-before-draw",
66
+ *[a.replace("{out}", str(out)) for a in args], f"file://{src}"]
67
+ try:
68
+ proc = subprocess.run(cmd, check=True, capture_output=True, timeout=TIMEOUT)
69
+ except (subprocess.SubprocessError, OSError) as exc:
70
+ raise ImageUnavailable("The slides could not be drawn.") from exc
71
+ if out and not out.exists():
72
+ raise ImageUnavailable("The slides could not be drawn.")
73
+ return (out.read_bytes() if out else b""), proc.stdout.decode("utf-8", "replace")
74
+
75
+
76
+ # --- PDF ------------------------------------------------------------------------------------------------------------
77
+
78
+ def render_pdf(deck: dict, theme: str, fmt: str, seed: int) -> bytes:
79
+ """The deck as a PDF, one slide per page at the slide's own size."""
80
+ theme = theme_id(theme)
81
+
82
+ def make() -> bytes:
83
+ html = deck_print_html(deck, theme, fmt, seed)
84
+ data, _ = _run_chrome(html, ["--print-to-pdf={out}", "--no-pdf-header-footer", "--print-to-pdf-no-header"],
85
+ "deck.pdf")
86
+ return data
87
+
88
+ return _cached(_key("pdf", deck, theme, fmt, seed), make)
89
+
90
+
91
+ PNG_BATCH = 8 # slides per Chrome run: one tall screenshot cut into slides (one launch per slide was the slow part)
92
+
93
+
94
+ def render_slide_pngs(deck: dict, theme: str, fmt: str, seed: int, limit: int) -> list[bytes]:
95
+ """The first `limit` slides as PNG images at the slide's own size (1920 x 1080), as a post carries them. A batch
96
+ of slides is drawn stacked on one page and the screenshot is cut at each slide."""
97
+ theme = theme_id(theme)
98
+ n = min(limit, len(deck["slides"]))
99
+ out: list[bytes] = []
100
+ for start in range(0, n, PNG_BATCH):
101
+ count = min(PNG_BATCH, n - start)
102
+ html = slides_html(deck, theme, fmt, seed, start, start + count)
103
+ shot, _ = _run_chrome(html, [f"--window-size={W},{H * count}", "--screenshot={out}"], "slides.png")
104
+ sheet = Image.open(io.BytesIO(shot)).convert("RGB")
105
+ for i in range(count):
106
+ buf = io.BytesIO()
107
+ sheet.crop((0, i * H, W, (i + 1) * H)).save(buf, "PNG", optimize=True)
108
+ out.append(buf.getvalue())
109
+ return out
110
+
111
+
112
+ # --- overflow check -------------------------------------------------------------------------------------------------
113
+
114
+ _MEASURE = """<script>
115
+ document.fonts.ready.then(function () {
116
+ var bad = [];
117
+ document.querySelectorAll('.slide').forEach(function (s, i) {
118
+ s.querySelectorAll('.t').forEach(function (t) {
119
+ var inner = t.firstElementChild; // the text's own height, not its glyphs' ink
120
+ if (inner.offsetHeight > t.clientHeight + 2 || inner.scrollWidth > t.clientWidth + 2) { if (bad.indexOf(i) < 0) bad.push(i); }
121
+ });
122
+ });
123
+ var out = document.createElement('pre'); out.id = 'overflow'; out.textContent = 'OVERFLOW:' + bad.join(',') + ':END';
124
+ document.body.appendChild(out);
125
+ });
126
+ </script>"""
127
+
128
+
129
+ def overflowing(deck: dict, theme: str, fmt: str, seed: int) -> set[int]:
130
+ """Indexes of slides where Chrome finds text taller or wider than its box. Raises ImageUnavailable without Chrome."""
131
+ html = deck_print_html(deck, theme_id(theme), fmt, seed, script=_MEASURE)
132
+ _, dom = _run_chrome(html, ["--dump-dom"], None)
133
+ m = re.search(r"OVERFLOW:([0-9,]*):END", dom)
134
+ if not m:
135
+ raise ImageUnavailable("The slides could not be measured.")
136
+ return {int(i) for i in m.group(1).split(",") if i}
137
+
138
+
139
+ # --- PowerPoint -----------------------------------------------------------------------------------------------------
140
+
141
+ def _rgb(hex_: str) -> RGBColor:
142
+ return RGBColor.from_string(hex_.lstrip("#").upper())
143
+
144
+
145
+ def _hex_rgb(hex_: str) -> tuple[int, int, int]:
146
+ h = hex_.lstrip("#")
147
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
148
+
149
+
150
+ def _gradient_disc(size: int, inner: tuple[int, int, int, int], outer: tuple[int, int, int, int]) -> Image.Image:
151
+ """A size x size image fading from `inner` at the centre to `outer` at the edge (RGBA)."""
152
+ mask = Image.radial_gradient("L").resize((size, size)) # 0 in the centre, 255 at the corners
153
+ mask = mask.point(lambda v: min(255, int(v * 1.42))) # 255 at the disc's edge
154
+ a, b = Image.new("RGBA", (size, size), inner), Image.new("RGBA", (size, size), outer)
155
+ return Image.composite(b, a, mask)
156
+
157
+
158
+ def background(decor: Decor, pal: Palette, dark: bool) -> bytes:
159
+ """The slide's sky and drawing as a JPEG, for the PowerPoint's background."""
160
+ hx, hy = decor.hot
161
+ sky = Image.new("RGBA", (W, H), _hex_rgb(pal.bg1) + (255,))
162
+ big = int(W * 1.7)
163
+ disc = _gradient_disc(big, _hex_rgb(pal.bg2) + (255,), _hex_rgb(pal.bg1) + (255,))
164
+ sky.paste(disc, (int(hx - big / 2), int(hy - big / 2)))
165
+ layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
166
+ draw = ImageDraw.Draw(layer)
167
+ for s in decor.shapes:
168
+ if s.kind in ("glow", "planet", "band"): # pasted straight onto the sky: put down the lines drawn so far
169
+ sky.alpha_composite(layer) # first, so the order is the page's (a ring's far half behind its planet)
170
+ layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
171
+ draw = ImageDraw.Draw(layer)
172
+ _draw(sky, layer, draw, s, pal, dark)
173
+ sky = Image.alpha_composite(sky, layer).convert("RGB")
174
+ buf = io.BytesIO()
175
+ sky.save(buf, "JPEG", quality=90)
176
+ return buf.getvalue()
177
+
178
+
179
+ def _draw(sky: Image.Image, layer: Image.Image, draw: ImageDraw.ImageDraw, s: Shape, pal: Palette, dark: bool) -> None:
180
+ c = _hex_rgb(color(pal, s.color))
181
+ if s.kind == "stars":
182
+ for x, y, r, o in s.points:
183
+ draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * o),))
184
+ elif s.kind == "glow":
185
+ size = int(s.r * 2)
186
+ if size < 4:
187
+ return
188
+ _paste_clipped(sky, _gradient_disc(size, c + (int(255 * s.opacity),), c + (0,)), s.cx - s.r, s.cy - s.r)
189
+ elif s.kind == "planet":
190
+ hi, lo, rim = planet_colors(pal, dark)
191
+ size = int(s.r * 2)
192
+ if size < 4:
193
+ return
194
+ disc = Image.new("RGBA", (size, size), _hex_rgb(lo) + (255,))
195
+ shine = _gradient_disc(int(size * 1.6), _hex_rgb(hi) + (255,), _hex_rgb(lo) + (0,))
196
+ _paste_clipped(disc, shine, -size * 0.45, -size * 0.52) # lit from the upper left, as in the page's gradient
197
+ mask = Image.new("L", (size, size), 0)
198
+ ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
199
+ disc.putalpha(mask)
200
+ _paste_clipped(sky, disc, s.cx - s.r, s.cy - s.r)
201
+ draw.ellipse((s.cx - s.r, s.cy - s.r, s.cx + s.r, s.cy + s.r), outline=_hex_rgb(rim) + (180,), width=3)
202
+ elif s.kind == "band":
203
+ sky.alpha_composite(_band(s, c))
204
+ elif s.kind == "ring" and s.arc:
205
+ draw.line(ring_path(s), fill=c + (int(255 * s.opacity),), width=max(1, round(s.width)), joint="curve")
206
+ elif s.kind == "ring":
207
+ steps = 360
208
+ rot = math.radians(s.rot)
209
+ pts = []
210
+ for k in range(steps + 1):
211
+ a = 2 * math.pi * k / steps
212
+ x, y = s.rx * math.cos(a), s.ry * math.sin(a)
213
+ pts.append((s.cx + x * math.cos(rot) - y * math.sin(rot), s.cy + x * math.sin(rot) + y * math.cos(rot)))
214
+ draw.line(pts, fill=c + (int(255 * s.opacity),), width=max(1, round(s.width)))
215
+ elif s.kind == "const":
216
+ for a, b in s.edges:
217
+ (x1, y1, *_), (x2, y2, *_) = s.points[a], s.points[b]
218
+ draw.line((x1, y1, x2, y2), fill=c + (int(255 * s.opacity * 0.5),), width=2)
219
+ for x, y, r, _ in s.points:
220
+ draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * s.opacity),))
221
+
222
+
223
+ def _band(s: Shape, c: tuple[int, int, int]) -> Image.Image:
224
+ """A ringed planet's band as a slide-sized layer: the shape filled, faded left to right as the page's SHADE."""
225
+ shape = Image.new("L", (W, H), 0)
226
+ ImageDraw.Draw(shape).polygon(ring_path(s) + ring_path(s, s.inner)[::-1], fill=255)
227
+ lo, hi = ring_shade(s)
228
+ ramp = []
229
+ for x in range(W):
230
+ t = min(1.0, max(0.0, (x - lo) / (hi - lo))) if hi > lo else 0.0
231
+ (o0, a0), (o1, a1) = next((SHADE[k], SHADE[k + 1]) for k in range(len(SHADE) - 1) if t <= SHADE[k + 1][0])
232
+ ramp.append(int(255 * s.opacity * (a0 + (a1 - a0) * (t - o0) / (o1 - o0))))
233
+ fade = Image.new("L", (W, 1))
234
+ fade.putdata(ramp)
235
+ alpha = Image.new("L", (W, H), 0)
236
+ alpha.paste(fade.resize((W, H)), mask=shape)
237
+ out = Image.new("RGBA", (W, H), c + (0,))
238
+ out.putalpha(alpha)
239
+ return out
240
+
241
+
242
+ def _paste_clipped(base: Image.Image, img: Image.Image, x: float, y: float) -> None:
243
+ """alpha_composite `img` onto `base` at (x, y), clipping whatever falls outside."""
244
+ x, y = int(x), int(y)
245
+ left, top = max(0, -x), max(0, -y)
246
+ right, bottom = min(img.width, base.width - x), min(img.height, base.height - y)
247
+ if right <= left or bottom <= top:
248
+ return
249
+ base.alpha_composite(img.crop((left, top, right, bottom)), (x + left, y + top))
250
+
251
+
252
+ def _emu(px: float) -> Emu:
253
+ return Emu(int(round(px * EMU_PX)))
254
+
255
+
256
+ def _no_style(shape) -> None:
257
+ """Drop the theme's default shape style (shadows, outlines) so only what we set is drawn."""
258
+ style = shape._element.find(qn("p:style"))
259
+ if style is not None:
260
+ shape._element.remove(style)
261
+
262
+
263
+ def _alpha(fill_fore_color_el, opacity: float) -> None:
264
+ if opacity >= 1:
265
+ return
266
+ clr = fill_fore_color_el.find(qn("a:srgbClr"))
267
+ if clr is not None:
268
+ a = clr.makeelement(qn("a:alpha"), {"val": str(int(opacity * 100000))})
269
+ clr.append(a)
270
+
271
+
272
+ def _add_el(slide, e: El, pal: Palette) -> None:
273
+ if e.kind == "text":
274
+ box = slide.shapes.add_textbox(_emu(e.x), _emu(e.y), _emu(e.w), _emu(e.h))
275
+ tf = box.text_frame
276
+ tf.word_wrap = True
277
+ tf.auto_size = MSO_AUTO_SIZE.NONE
278
+ tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
279
+ tf.vertical_anchor = {"middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}.get(e.valign, MSO_ANCHOR.TOP)
280
+ for i, runs in enumerate(e.paras):
281
+ p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
282
+ p.alignment = {"center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(e.align, PP_ALIGN.LEFT)
283
+ p.line_spacing = e.lh * 0.96
284
+ if i and e.gap:
285
+ p.space_before = Pt(e.gap * 0.5)
286
+ for r in runs:
287
+ run = p.add_run()
288
+ run.text = r.text
289
+ f = run.font
290
+ f.name = FONTS[e.font]
291
+ f.size = Pt(e.size * 0.5)
292
+ f.bold = r.bold or e.font == "display"
293
+ f.color.rgb = _rgb(color(pal, r.color))
294
+ rpr = run._r.get_or_add_rPr()
295
+ if e.upper:
296
+ rpr.set("cap", "all")
297
+ if e.track:
298
+ rpr.set("spc", str(int(e.track * e.size * 0.5 * 100)))
299
+ return
300
+ if e.kind == "line":
301
+ horizontal = e.h == 0
302
+ x2, y2 = (e.x + e.w, e.y) if horizontal else (e.x, e.y + e.h)
303
+ ln = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, _emu(e.x), _emu(e.y), _emu(x2), _emu(y2))
304
+ _no_style(ln)
305
+ ln.line.color.rgb = _rgb(color(pal, e.stroke))
306
+ ln.line.width = _emu(e.stroke_w)
307
+ return
308
+ kind = MSO_SHAPE.OVAL if e.kind == "circle" else (MSO_SHAPE.ROUNDED_RECTANGLE if e.radius else MSO_SHAPE.RECTANGLE)
309
+ sh = slide.shapes.add_shape(kind, _emu(e.x), _emu(e.y), _emu(e.w), _emu(e.h))
310
+ _no_style(sh)
311
+ if kind == MSO_SHAPE.ROUNDED_RECTANGLE:
312
+ sh.adjustments[0] = min(0.5, e.radius / max(min(e.w, e.h), 1))
313
+ if e.fill:
314
+ sh.fill.solid()
315
+ sh.fill.fore_color.rgb = _rgb(color(pal, e.fill))
316
+ _alpha(sh.fill._xPr.find(qn("a:solidFill")), e.opacity)
317
+ else:
318
+ sh.fill.background()
319
+ if e.stroke and e.stroke_w:
320
+ sh.line.color.rgb = _rgb(color(pal, e.stroke))
321
+ sh.line.width = _emu(e.stroke_w)
322
+ else:
323
+ sh.line.fill.background()
324
+
325
+
326
+ def _notes(slide_data: dict) -> str:
327
+ notes = slide_data.get("notes") or ""
328
+ srcs = [f"{s.get('title') or s['path']} (lines {s['line_start']}-{s['line_end']})" for s in slide_data.get("sources") or []]
329
+ return notes + (("\n\nSources: " + "; ".join(dict.fromkeys(srcs))) if srcs else "")
330
+
331
+
332
+ def build_pptx(deck: dict, theme: str, fmt: str, seed: int) -> bytes:
333
+ """The deck as an editable PowerPoint: text boxes you can type in, over each slide's drawn background."""
334
+ theme = theme_id(theme)
335
+
336
+ def make() -> bytes:
337
+ t = THEMES[theme]
338
+ prs = Presentation()
339
+ prs.slide_width, prs.slide_height = _emu(W), _emu(H)
340
+ blank = prs.slide_layouts[6]
341
+ for i in range(len(deck["slides"])):
342
+ c = compose(deck, i, theme, fmt, seed)
343
+ slide = prs.slides.add_slide(blank)
344
+ d = build(theme, c.slide["layout"], c.slide["variant"], seed, i)
345
+ slide.shapes.add_picture(io.BytesIO(background(d, t.pal, t.dark)), 0, 0, _emu(W), _emu(H))
346
+ for e in c.elements:
347
+ _add_el(slide, e, t.pal)
348
+ note = _notes(c.slide)
349
+ if note:
350
+ slide.notes_slide.notes_text_frame.text = note
351
+ prs.core_properties.title = deck.get("title") or "Slide deck"
352
+ buf = io.BytesIO()
353
+ prs.save(buf)
354
+ return buf.getvalue()
355
+
356
+ return _cached(_key("pptx", deck, theme, fmt, seed), make)
backend/app/slides/layout.py ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Where everything on a slide goes. Each theme has its own composition for every layout variant (GEOMETRY), the
2
+ variant of each slide is picked by code (assign_variants), and compose() turns one slide into placed elements: boxes
3
+ in pixels on the 1920 x 1080 canvas, each with its text and font size. The HTML and the PowerPoint both draw these
4
+ same elements, so the two always match.
5
+
6
+ Text is measured here before it is placed: every text box gets the largest font size at which its text fits, and
7
+ text that cannot fit even at the smallest size is shortened. Nothing is ever drawn outside its box."""
8
+
9
+ import copy
10
+ import math
11
+ import random
12
+ import re
13
+ from dataclasses import dataclass, field
14
+
15
+ from app.slides.content import LIMITS, clip
16
+ from app.slides.metrics import WIDTHS
17
+ from app.slides.themes import theme_id
18
+
19
+ # --- geometry -------------------------------------------------------------------------------------------------------
20
+ # A block is (x, y, w, h[, align[, valign[, kind]]]). `kind` says how a body, a column or the closing line is drawn.
21
+ # Night stellar (dark-space): cinematic, centred openers and closers, asymmetric splits. Moon light (light-space):
22
+ # editorial, left aligned, top anchored, mirrored splits.
23
+
24
+ GEOMETRY: dict[str, dict[str, dict[str, dict[str, tuple]]]] = {
25
+ "dark-space": {
26
+ "title": {
27
+ "a": {"kicker": (360, 258, 1200, 40, "center"), "heading": (160, 312, 1600, 300, "center", "middle"),
28
+ "lead": (360, 636, 1200, 130, "center")},
29
+ "b": {"kicker": (140, 512, 1100, 40), "heading": (140, 562, 1150, 290, "left", "bottom"),
30
+ "lead": (140, 872, 1000, 120)},
31
+ "c": {"kicker": (140, 286, 860, 40), "heading": (140, 340, 880, 400, "left", "middle"),
32
+ "lead": (140, 762, 860, 150)},
33
+ },
34
+ "agenda": {
35
+ "a": {"kicker": (140, 160, 700, 40), "heading": (140, 212, 700, 420),
36
+ "body": (960, 140, 820, 820, "left", "middle", "agenda_list")},
37
+ "b": {"kicker": (360, 110, 1200, 40, "center"), "heading": (260, 160, 1400, 130, "center", "middle"),
38
+ "body": (220, 340, 1480, 620, "left", "top", "agenda_grid")},
39
+ },
40
+ "section": {
41
+ "a": {"kicker": (360, 368, 1200, 40, "center"), "heading": (210, 420, 1500, 240, "center", "middle"),
42
+ "lead": (410, 684, 1100, 150, "center")},
43
+ "b": {"kicker": (140, 552, 1100, 40), "heading": (140, 604, 1300, 240, "left", "bottom"),
44
+ "lead": (140, 864, 1100, 120)},
45
+ },
46
+ "points": {
47
+ "a": {"kicker": (140, 100, 1300, 40), "heading": (140, 150, 1360, 150), "lead": (140, 314, 1300, 110),
48
+ "body": (140, 446, 1300, 534, "left", "top", "list")},
49
+ "b": {"kicker": (140, 160, 640, 40), "heading": (140, 212, 640, 500), "lead": (140, 730, 640, 240),
50
+ "body": (880, 130, 900, 840, "left", "middle", "list")},
51
+ "c": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
52
+ "body": (140, 432, 1640, 548, "left", "top", "cards")},
53
+ "d": {"kicker": (140, 160, 700, 40), "heading": (140, 212, 700, 430), "lead": (140, 662, 700, 300),
54
+ "body": (930, 110, 850, 860, "left", "middle", "timeline_v")},
55
+ },
56
+ "two_column": {
57
+ "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
58
+ "left": (140, 432, 790, 548, "left", "top", "col"), "right": (990, 432, 790, 548, "left", "top", "col")},
59
+ "b": {"kicker": (360, 90, 1200, 40, "center"), "heading": (210, 140, 1500, 130, "center", "middle"),
60
+ "lead": (360, 282, 1200, 90, "center"),
61
+ "left": (140, 410, 780, 570, "left", "top", "panel"), "right": (1000, 410, 780, 570, "left", "top", "panel")},
62
+ },
63
+ "stat": {
64
+ "a": {"kicker": (360, 120, 1200, 40, "center"), "heading": (260, 170, 1400, 130, "center", "middle"),
65
+ "value": (160, 318, 1600, 300, "center", "middle"), "label": (360, 630, 1200, 120, "center"),
66
+ "lead": (410, 776, 1100, 180, "center")},
67
+ "b": {"kicker": (140, 150, 820, 40), "value": (140, 206, 820, 420, "left", "middle"),
68
+ "label": (140, 650, 820, 300), "heading": (1060, 160, 720, 290, "left", "bottom"),
69
+ "lead": (1060, 480, 720, 480)},
70
+ },
71
+ "quote": {
72
+ "a": {"kicker": (360, 150, 1200, 40, "center"), "quote": (210, 214, 1500, 500, "center", "middle"),
73
+ "by": (360, 740, 1200, 60, "center"), "heading": (360, 846, 1200, 110, "center")},
74
+ "b": {"kicker": (140, 150, 640, 40), "heading": (140, 202, 640, 400), "lead": (140, 630, 640, 320),
75
+ "quote": (900, 160, 880, 640, "left", "middle"), "by": (900, 820, 880, 60)},
76
+ },
77
+ "closing": {
78
+ "a": {"kicker": (360, 90, 1200, 40, "center"), "heading": (210, 140, 1500, 120, "center", "middle"),
79
+ "body": (140, 300, 1640, 430, "center", "top", "take_arc"),
80
+ "closing": (260, 760, 1400, 190, "center", "middle", "line")},
81
+ "b": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 120),
82
+ "body": (140, 308, 1640, 450, "left", "top", "take_cols"),
83
+ "closing": (140, 800, 1640, 170, "left", "middle", "band")},
84
+ "c": {"kicker": (140, 190, 820, 40), "closing": (140, 244, 820, 600, "left", "middle", "big"),
85
+ "heading": (1060, 150, 720, 110), "body": (1060, 290, 720, 680, "left", "top", "take_stack")},
86
+ },
87
+ },
88
+ "light-space": {
89
+ "title": {
90
+ "a": {"kicker": (140, 318, 1150, 40), "heading": (140, 368, 1150, 350), "lead": (140, 738, 1000, 150)},
91
+ "b": {"kicker": (140, 120, 1640, 40), "heading": (140, 174, 1640, 430, "left", "bottom"), "lead": (140, 640, 1150, 170)},
92
+ "c": {"kicker": (360, 326, 1200, 40, "center"), "heading": (300, 378, 1320, 270, "center", "middle"),
93
+ "lead": (410, 670, 1100, 130, "center")},
94
+ },
95
+ "agenda": {
96
+ "a": {"kicker": (140, 110, 1640, 40), "heading": (140, 160, 1640, 130),
97
+ "body": (140, 336, 1640, 630, "left", "top", "agenda_grid")},
98
+ "b": {"kicker": (140, 376, 640, 40), "heading": (140, 428, 640, 320),
99
+ "body": (900, 130, 880, 840, "left", "middle", "agenda_list")},
100
+ },
101
+ "section": {
102
+ "a": {"kicker": (140, 408, 1300, 40), "heading": (140, 460, 1300, 240), "lead": (140, 716, 1100, 150)},
103
+ "b": {"kicker": (620, 408, 1160, 40, "right"), "heading": (480, 460, 1300, 240, "right"),
104
+ "lead": (680, 716, 1100, 150, "right")},
105
+ },
106
+ "points": {
107
+ "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1500, 100),
108
+ "body": (140, 432, 1640, 548, "left", "top", "list2")},
109
+ "b": {"kicker": (1180, 160, 600, 40), "heading": (1180, 212, 600, 500), "lead": (1180, 730, 600, 240),
110
+ "body": (140, 130, 940, 840, "left", "middle", "list")},
111
+ "c": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
112
+ "body": (140, 432, 1640, 548, "left", "top", "cards")},
113
+ "d": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
114
+ "body": (140, 440, 1640, 540, "left", "top", "timeline_h")},
115
+ },
116
+ "two_column": {
117
+ "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
118
+ "left": (140, 432, 790, 548, "left", "top", "panel"), "right": (990, 432, 790, 548, "left", "top", "panel")},
119
+ "b": {"kicker": (140, 160, 560, 40), "heading": (140, 212, 560, 420), "lead": (140, 650, 560, 320),
120
+ "left": (780, 130, 1000, 400, "left", "top", "col"), "right": (780, 566, 1000, 400, "left", "top", "col")},
121
+ },
122
+ "stat": {
123
+ "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140),
124
+ "value": (140, 330, 900, 360, "left", "middle"), "label": (140, 700, 900, 260),
125
+ "lead": (1120, 360, 660, 600)},
126
+ "b": {"kicker": (360, 130, 1200, 40, "center"), "value": (260, 186, 1400, 320, "center", "middle"),
127
+ "label": (360, 526, 1200, 120, "center"), "heading": (260, 690, 1400, 110, "center", "middle"),
128
+ "lead": (410, 820, 1100, 150, "center")},
129
+ },
130
+ "quote": {
131
+ "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 110),
132
+ "quote": (250, 300, 1430, 470, "left", "middle"), "by": (250, 796, 1430, 60)},
133
+ "b": {"kicker": (620, 130, 1160, 40, "right"), "heading": (620, 180, 1160, 110, "right"),
134
+ "quote": (420, 330, 1360, 450, "right", "middle"), "by": (620, 806, 1160, 60, "right")},
135
+ },
136
+ "closing": {
137
+ "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 120),
138
+ "body": (140, 318, 1640, 440, "left", "top", "take_cols"),
139
+ "closing": (140, 800, 1640, 170, "left", "middle", "band")},
140
+ "b": {"kicker": (140, 110, 1640, 40), "closing": (140, 160, 1500, 390, "left", "top", "big"),
141
+ "heading": (140, 590, 1640, 80), "body": (140, 690, 1640, 290, "left", "top", "take_row")},
142
+ "c": {"kicker": (360, 130, 1200, 40, "center"), "heading": (260, 180, 1400, 110, "center", "middle"),
143
+ "body": (410, 330, 1100, 410, "left", "top", "take_stack"),
144
+ "closing": (260, 772, 1400, 180, "center", "middle", "line")},
145
+ },
146
+ },
147
+ }
148
+
149
+ VARIANTS = {layout: tuple(GEOMETRY["dark-space"][layout]) for layout in GEOMETRY["dark-space"]}
150
+
151
+
152
+ def block(theme: str, layout: str, variant: str, name: str) -> tuple | None:
153
+ g = GEOMETRY[theme_id(theme)][layout].get(variant) or next(iter(GEOMETRY[theme_id(theme)][layout].values()))
154
+ b = g.get(name)
155
+ if not b:
156
+ return None
157
+ x, y, w, h, *rest = b
158
+ return (x, y, w, h, rest[0] if rest else "left", rest[1] if len(rest) > 1 else "top", rest[2] if len(rest) > 2 else "")
159
+
160
+
161
+ # --- picking variants -----------------------------------------------------------------------------------------------
162
+
163
+ def fits_variant(slide: dict, theme: str, variant: str) -> bool:
164
+ """Whether a variant suits this slide's content (a card row needs 3 or 4 points, a timeline 3 to 5)."""
165
+ layout = slide["layout"]
166
+ n = len(slide["points"])
167
+ kind = (block(theme, layout, variant, "body") or (0, 0, 0, 0, "", "", ""))[6]
168
+ if kind == "cards":
169
+ return 3 <= n <= 4
170
+ if kind in ("timeline_v", "timeline_h"):
171
+ return 3 <= n <= 5
172
+ if kind == "list2":
173
+ return n >= 3
174
+ if kind == "agenda_list":
175
+ return n <= 7
176
+ return True
177
+
178
+
179
+ def assign_variants(slides: list[dict], theme: str, seed: int, previous: list[tuple[str, str]] | None = None) -> None:
180
+ """Give every slide a variant, in place. Only variants that suit the slide are considered; the previous slide's
181
+ layout and variant is never repeated; variants not used yet in this deck come first; and the variant the last deck
182
+ in the notebook had at the same position is avoided. Ties are broken by `seed` (the deck's own id), so every new
183
+ deck differs while one deck always draws the same."""
184
+ theme = theme_id(theme)
185
+ rnd = random.Random(seed)
186
+ used: dict[tuple[str, str], int] = {}
187
+ before: tuple[str, str] | None = None
188
+ previous = previous or []
189
+ for i, s in enumerate(slides):
190
+ options = [v for v in VARIANTS[s["layout"]] if fits_variant(s, theme, v)] or list(VARIANTS[s["layout"]])
191
+ fresh = [v for v in options if (s["layout"], v) != before]
192
+ if not fresh: # the only variant that suits it was just used: any other one draws it too, so never repeat
193
+ fresh = [v for v in VARIANTS[s["layout"]] if (s["layout"], v) != before] or options
194
+ last_time = previous[i] if i < len(previous) else None
195
+
196
+ scores = {v: (used.get((s["layout"], v), 0), 1 if last_time == (s["layout"], v) else 0, rnd.random())
197
+ for v in fresh}
198
+ choice = min(fresh, key=scores.__getitem__)
199
+ s["variant"] = choice
200
+ used[(s["layout"], choice)] = used.get((s["layout"], choice), 0) + 1
201
+ before = (s["layout"], choice)
202
+
203
+
204
+ # --- measuring text -------------------------------------------------------------------------------------------------
205
+
206
+ def _char_w(c: str, font: str, bold: bool) -> float:
207
+ """Advance of one character in em, from the fonts' measured widths, padded a little so real text is never wider."""
208
+ if font == "mono":
209
+ return 0.62
210
+ table = WIDTHS["display_bold" if font == "display" else "body_bold" if bold else "body"]
211
+ w = table.get(c)
212
+ if w is None:
213
+ w = 1.0 if ord(c) >= 0x2E80 else 0.64 # full width scripts, and anything else not measured
214
+ return w * 1.03
215
+
216
+
217
+ @dataclass
218
+ class Run:
219
+ text: str
220
+ bold: bool = False
221
+ color: str = "ink"
222
+ field: str = "" # where the text lives in the slide ("heading", "points.2.text"), so the page can edit it; "" is ours
223
+
224
+
225
+ def _lines(runs: list[Run], width: float, size: float, font: str, upper: bool, track: float) -> int:
226
+ """Lines a paragraph wraps to in `width` pixels."""
227
+ words: list[tuple[str, bool]] = []
228
+ for r in runs:
229
+ t = r.text.upper() if upper else r.text
230
+ words += [(w, r.bold) for w in t.split(" ") if w]
231
+ if not words:
232
+ return 0
233
+ space = (_char_w(" ", font, False) + track) * size
234
+ lines, x = 1, 0.0
235
+ for word, bold in words:
236
+ ww = sum((_char_w(c, font, bold) + track) * size for c in word)
237
+ if ww > width: # a word longer than the line breaks anywhere
238
+ extra = x + (space if x else 0) + ww
239
+ lines += int(extra // width)
240
+ x = extra % width
241
+ continue
242
+ need = ww if x == 0 else x + space + ww
243
+ if need <= width:
244
+ x = need
245
+ else:
246
+ lines += 1
247
+ x = ww
248
+ return lines
249
+
250
+
251
+ # --- placed elements ------------------------------------------------------------------------------------------------
252
+
253
+ @dataclass
254
+ class El:
255
+ kind: str # text | rect | circle | line
256
+ role: str
257
+ x: float
258
+ y: float
259
+ w: float
260
+ h: float
261
+ paras: list[list[Run]] = field(default_factory=list)
262
+ size: float = 0
263
+ lh: float = 1.3
264
+ font: str = "body" # display | body | mono
265
+ align: str = "left"
266
+ valign: str = "top"
267
+ upper: bool = False
268
+ track: float = 0.0 # letter spacing in em
269
+ gap: float = 0.0 # space between paragraphs, in px
270
+ fill: str | None = None # palette key
271
+ stroke: str | None = None
272
+ stroke_w: float = 0
273
+ radius: float = 0
274
+ opacity: float = 1.0
275
+
276
+ def text_height(self) -> float:
277
+ return text_height(self.paras, self.w, self.size, self.lh, self.font, self.upper, self.track, self.gap)
278
+
279
+
280
+ def text_height(paras: list[list[Run]], width: float, size: float, lh: float, font: str, upper: bool = False,
281
+ track: float = 0.0, gap: float = 0.0) -> float:
282
+ n = [_lines(p, width, size, font, upper, track) for p in paras]
283
+ used = [k for k in n if k]
284
+ return sum(k * size * lh for k in used) + max(len(used) - 1, 0) * gap
285
+
286
+
287
+ SCALES = (1.0, 0.92, 0.85, 0.78, 0.72, 0.66, 0.6, 0.55)
288
+ GROW = (1.4, 1.28, 1.18, 1.08) # sparse content is drawn larger, so a slide with little on it is not half empty
289
+
290
+
291
+ def _scales(start: int, grow: bool = False) -> tuple[float, ...]:
292
+ """Scales to try, largest first: from `start` down, with the growing ones first when allowed and not shrunk."""
293
+ return (GROW + SCALES) if grow and start == 0 else SCALES[start:]
294
+
295
+ # Base font sizes in px, by format (both themes). Presenter slides carry fewer words, so they are drawn larger.
296
+ TYPE = {
297
+ "detailed": {"kicker": 24, "heading": 66, "title": 110, "section": 88, "lead": 33, "title_lead": 37, "point": 31,
298
+ "card_term": 33, "card_text": 28, "value": 242, "label": 37, "quote": 57, "by": 26, "take": 31,
299
+ "closing": 51, "closing_big": 73, "agenda": 35, "col_label": 31, "col_item": 30, "num": 24,
300
+ "footer": 20},
301
+ "presenter": {"kicker": 26, "heading": 79, "title": 123, "section": 101, "lead": 40, "title_lead": 42,
302
+ "point": 44, "card_term": 37, "card_text": 37, "value": 286, "label": 44, "quote": 66, "by": 29,
303
+ "take": 40, "closing": 62, "closing_big": 86, "agenda": 40, "col_label": 33, "col_item": 40,
304
+ "num": 26, "footer": 20},
305
+ }
306
+
307
+
308
+ def _fit_size(paras: list[list[Run]], width: float, height: float, base: float, lh: float, font: str,
309
+ start: int, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, grow: bool = False) -> float | None:
310
+ """The largest size (base times a scale, from `start` down) at which the paragraphs fit the box, or None."""
311
+ for scale in _scales(start, grow):
312
+ size = round(base * scale, 1)
313
+ if text_height(paras, width, size, lh, font, upper, track, size * gap_em) <= height:
314
+ return size
315
+ return None
316
+
317
+
318
+ class NoFit(Exception):
319
+ """Text that does not fit even at the smallest size: the caller shortens it and tries again."""
320
+
321
+ def __init__(self, field_name: str, index: int | None = None):
322
+ super().__init__(field_name)
323
+ self.field_name = field_name
324
+ self.index = index
325
+
326
+
327
+ @dataclass
328
+ class Composed:
329
+ slide: dict # the slide as drawn (text shortened when it had to be)
330
+ elements: list[El]
331
+ seed: int
332
+
333
+
334
+ def _text(role: str, rect: tuple, paras: list[list[Run]], base: float, start: int, *, font: str = "body",
335
+ lh: float = 1.35, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, field_name: str = "",
336
+ index: int | None = None, grow: bool = False) -> El:
337
+ x, y, w, h, align, valign = rect[:6]
338
+ size = _fit_size(paras, w, h, base, lh, font, start, upper, track, gap_em, grow)
339
+ if size is None:
340
+ raise NoFit(field_name or role, index)
341
+ return El("text", role, x, y, w, h, paras=paras, size=size, lh=lh, font=font, align=align, valign=valign,
342
+ upper=upper, track=track, gap=size * gap_em)
343
+
344
+
345
+ def _point_runs(p: dict, i: int, fmt: str) -> list[Run]:
346
+ if p["term"] and fmt == "detailed":
347
+ return [Run(p["term"], True, "ink", f"points.{i}.term"), Run(". ", True, "ink"),
348
+ Run(p["text"], False, "mute", f"points.{i}.text")]
349
+ return [Run(p["text"], False, "ink", f"points.{i}.text")]
350
+
351
+
352
+ def _body(els: list[El], s: dict, rect: tuple, kind: str, fmt: str, t: dict, start: int, dark: bool) -> None:
353
+ """The slide's main content: points, agenda items or takeaways, drawn as `kind`."""
354
+ x, y, w, h, _align, valign, _ = rect
355
+ pts = s["points"]
356
+
357
+ if kind in ("list", "list2", "agenda_list", "agenda_grid", "take_stack"):
358
+ if kind in ("agenda_list", "agenda_grid"):
359
+ items = [[Run(p["text"], False, "ink", f"points.{i}.text")] for i, p in enumerate(pts)]
360
+ base, numbered = t["agenda"], True
361
+ elif kind == "take_stack":
362
+ items = [[Run(tx, False, "ink", f"takeaways.{i}")] for i, tx in enumerate(s["takeaways"])]
363
+ base, numbered = t["take"], True
364
+ else:
365
+ items = [_point_runs(p, i, fmt) for i, p in enumerate(pts)]
366
+ base, numbered = t["point"], False
367
+ cols = 2 if kind in ("list2", "agenda_grid") and len(items) >= 3 else 1
368
+ per = math.ceil(len(items) / cols)
369
+ col_gap = 70
370
+ cw = (w - col_gap * (cols - 1)) / cols
371
+ indent = 78 if numbered else 44
372
+ tw = cw - indent
373
+ # One size for every item: the largest at which the tallest column fits with breathing room between items.
374
+ size = None
375
+ for scale in _scales(start, True):
376
+ sz = round(base * scale, 1)
377
+ gap = sz * 0.9
378
+ heights = [text_height([it], tw, sz, 1.38, "body") for it in items]
379
+ tallest = max(sum(heights[c * per:(c + 1) * per]) + gap * (len(heights[c * per:(c + 1) * per]) - 1)
380
+ for c in range(cols))
381
+ if tallest <= h:
382
+ size = sz
383
+ break
384
+ if size is None:
385
+ raise NoFit("takeaways" if kind == "take_stack" else "points")
386
+ heights = [text_height([it], tw, size, 1.38, "body") for it in items]
387
+ for c in range(cols):
388
+ chunk = list(range(c * per, min((c + 1) * per, len(items))))
389
+ total = sum(heights[i] for i in chunk)
390
+ free = h - total
391
+ gap = min(free / max(len(chunk) - 1, 1), size * 2.2) if len(chunk) > 1 else 0
392
+ block_h = total + gap * max(len(chunk) - 1, 0)
393
+ cy = y + (h - block_h) / 2 if valign == "middle" else y
394
+ cx = x + c * (cw + col_gap)
395
+ for i in chunk:
396
+ ih = heights[i]
397
+ if numbered:
398
+ els.append(El("text", "num", cx, cy + size * 0.12, 64, size * 1.3, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
399
+ size=round(size * 0.82, 1), lh=1.2, font="mono"))
400
+ else:
401
+ d = size * 0.34
402
+ els.append(El("circle", "dot", cx + 4, cy + size * 0.69 - d / 2, d, d, fill="accent"))
403
+ els.append(El("text", "item", cx + indent, cy, tw, ih + 2, paras=[items[i]], size=size, lh=1.38))
404
+ if numbered and kind != "take_stack" and i != chunk[-1]:
405
+ els.append(El("line", "rule", cx + indent, cy + ih + gap / 2, tw, 0, stroke="line", stroke_w=1.5))
406
+ cy += ih + gap
407
+ return
408
+
409
+ if kind in ("cards", "take_cols", "take_row", "take_arc"):
410
+ if kind == "cards":
411
+ cells = [(p["term"] if fmt == "detailed" else "", p["text"]) for p in pts]
412
+ else:
413
+ cells = [("", tx) for tx in s["takeaways"]]
414
+ n = max(len(cells), 1)
415
+ gap = 36 if kind != "take_row" else 60
416
+ cw = (w - gap * (n - 1)) / n
417
+ boxed = kind in ("cards", "take_cols")
418
+ pad = 40 if boxed else 0
419
+ top = 0.0
420
+ if kind == "take_arc":
421
+ top = 120 # the glowing marker above each takeaway
422
+ elif kind == "take_row":
423
+ top = 70
424
+ else:
425
+ top = 64 # number row inside the card
426
+ inner_w = cw - 2 * pad
427
+ inner_h = h - 2 * pad - top
428
+ term_base = t["card_term"]
429
+ text_base = t["card_text"] if kind == "cards" else t["take"]
430
+ size = None
431
+ for scale in _scales(start, True):
432
+ ts, xs = round(term_base * scale, 1), round(text_base * scale, 1)
433
+ ok = True
434
+ for term, text in cells:
435
+ hh = (text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0)
436
+ hh += text_height([[Run(text)]], inner_w, xs, 1.4, "body")
437
+ if hh > inner_h:
438
+ ok = False
439
+ break
440
+ if ok:
441
+ size = (ts, xs)
442
+ break
443
+ if size is None:
444
+ raise NoFit("takeaways" if kind != "cards" else "points")
445
+ ts, xs = size
446
+ # Cards as tall as the tallest content (not the whole box), so short text does not float in a tall card.
447
+ tallest = max((text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0)
448
+ + text_height([[Run(text)]], inner_w, xs, 1.4, "body") for term, text in cells)
449
+ h = min(h, tallest + 2 * pad + top + 12)
450
+ for i, (term, text) in enumerate(cells):
451
+ cx = x + i * (cw + gap)
452
+ if boxed:
453
+ els.append(El("rect", "card", cx, y, cw, h, fill="card", stroke="cardline", stroke_w=2, radius=22))
454
+ if kind == "take_arc":
455
+ r = 46
456
+ els.append(El("circle", "halo", cx + cw / 2 - r * 1.5, y + 2 - r * 0.5, r * 3, r * 3, fill="accent2", opacity=0.18))
457
+ els.append(El("circle", "marker", cx + cw / 2 - r, y + 2, r * 2, r * 2, fill="accent2"))
458
+ els.append(El("text", "num", cx + cw / 2 - r, y + 2, r * 2, r * 2, paras=[[Run(f"{i + 1}", True, "white")]],
459
+ size=34, lh=1.0, font="display", align="center", valign="middle"))
460
+ elif kind == "take_row":
461
+ els.append(El("rect", "bar", cx, y, cw, 5, fill="accent"))
462
+ els.append(El("text", "num", cx, y + 20, cw, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
463
+ size=24, lh=1.2, font="mono"))
464
+ else:
465
+ els.append(El("text", "num", cx + pad, y + pad, inner_w, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
466
+ size=t["num"], lh=1.2, font="mono"))
467
+ cy = y + pad + top
468
+ align = "center" if kind == "take_arc" else "left"
469
+ if term:
470
+ th = text_height([[Run(term, True)]], inner_w, ts, 1.15, "display")
471
+ els.append(El("text", "term", cx + pad, cy, inner_w, th + 2, paras=[[Run(term, True, "ink", f"points.{i}.term")]], size=ts,
472
+ lh=1.15, font="display", align=align))
473
+ cy += th + ts * 0.5
474
+ where = f"points.{i}.text" if kind == "cards" else f"takeaways.{i}"
475
+ els.append(El("text", "item", cx + pad, cy, inner_w, y + h - pad - cy,
476
+ paras=[[Run(text, False, "mute" if term else "ink", where)]], size=xs, lh=1.4, align=align))
477
+ return
478
+
479
+ if kind in ("timeline_v", "timeline_h"):
480
+ items = [_point_runs(p, i, fmt) for i, p in enumerate(pts)]
481
+ n = len(items)
482
+ r = 28
483
+ if kind == "timeline_v":
484
+ tw = w - 96
485
+ size = None
486
+ for scale in _scales(start, True):
487
+ sz = round(t["point"] * scale, 1)
488
+ heights = [max(text_height([it], tw, sz, 1.38, "body"), r * 2) for it in items]
489
+ if sum(heights) + sz * 1.2 * (n - 1) <= h:
490
+ size = sz
491
+ break
492
+ if size is None:
493
+ raise NoFit("points")
494
+ heights = [max(text_height([it], tw, size, 1.38, "body"), r * 2) for it in items]
495
+ gap = min((h - sum(heights)) / max(n - 1, 1), size * 2.4)
496
+ total = sum(heights) + gap * (n - 1)
497
+ cy = y + (h - total) / 2 if valign == "middle" else y
498
+ els.append(El("line", "spine", x + r, cy + r, 0, total - 2 * r if total > 2 * r else 0, stroke="line", stroke_w=3))
499
+ for i, it in enumerate(items):
500
+ els.append(El("circle", "marker", x, cy, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3))
501
+ els.append(El("text", "num", x, cy, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0,
502
+ font="display", align="center", valign="middle"))
503
+ els.append(El("text", "item", x + 96, cy + max((r * 2 - size * 1.38) / 2, 0) if heights[i] <= r * 2 else cy,
504
+ tw, heights[i] + 2, paras=[it], size=size, lh=1.38))
505
+ cy += heights[i] + gap
506
+ else:
507
+ gap = 40
508
+ cw = (w - gap * (n - 1)) / n
509
+ th = h - r * 2 - 40
510
+ size = _fit_all([[it] for it in items], cw, th, t["point"] * 0.9, 1.38, start)
511
+ if size is None:
512
+ raise NoFit("points")
513
+ els.append(El("line", "spine", x + r, y + r, w - 2 * r, 0, stroke="line", stroke_w=3))
514
+ for i, it in enumerate(items):
515
+ cx = x + i * (cw + gap)
516
+ els.append(El("circle", "marker", cx, y, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3))
517
+ els.append(El("text", "num", cx, y, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0,
518
+ font="display", align="center", valign="middle"))
519
+ els.append(El("text", "item", cx, y + r * 2 + 40, cw, th, paras=[it], size=size, lh=1.38))
520
+ return
521
+ raise ValueError(f"unknown body kind {kind}")
522
+
523
+
524
+ def _fit_all(blocks: list[list[list[Run]]], width: float, height: float, base: float, lh: float, start: int) -> float | None:
525
+ for scale in _scales(start, True):
526
+ size = round(base * scale, 1)
527
+ if all(text_height(b, width, size, lh, "body") <= height for b in blocks):
528
+ return size
529
+ return None
530
+
531
+
532
+ def _columns(els: list[El], s: dict, rects: tuple[tuple, tuple], t: dict, start: int) -> None:
533
+ """The two sides of a comparison, at one font size, each panel as tall as the taller side's content."""
534
+ kind = rects[0][6]
535
+ pad = 44 if kind == "panel" else 0
536
+ top = 0 if kind == "panel" else 28
537
+ sides = [s["left"], s["right"]]
538
+
539
+ def measure(col: dict, iw: float, ls: float, xs: float) -> tuple[float, list[float]]:
540
+ label = [[Run(col["label"], True, "accent")]] if col["label"] else []
541
+ lab = text_height(label, iw, ls, 1.2, "display") + (xs * 0.9 if label else 0)
542
+ heights = [text_height([[Run(i)]], iw - 40, xs, 1.38, "body") for i in col["items"]]
543
+ return lab + sum(heights) + xs * 0.75 * (len(heights) - 1), heights
544
+
545
+ for scale in _scales(start, True):
546
+ ls, xs = round(t["col_label"] * scale, 1), round(t["col_item"] * scale, 1)
547
+ need = [measure(col, r[2] - 2 * pad, ls, xs)[0] for col, r in zip(sides, rects, strict=True)]
548
+ bad = [i for i, (n, r) in enumerate(zip(need, rects, strict=True)) if n > r[3] - 2 * pad - top]
549
+ if not bad:
550
+ break
551
+ else:
552
+ raise NoFit(("left", "right")[bad[0]] + "_items")
553
+ panel_h = max(need) + 2 * pad + top + 8
554
+ for side, col, (x, y, w, h, *_) in zip(("left", "right"), sides, rects, strict=True):
555
+ if kind == "panel":
556
+ els.append(El("rect", "panel", x, y, w, min(h, panel_h), fill="card", stroke="cardline", stroke_w=2, radius=22))
557
+ else:
558
+ els.append(El("rect", "bar", x, y, 72, 5, fill="accent"))
559
+ ix, iw = x + pad, w - 2 * pad
560
+ cy = y + pad + top
561
+ _, heights = measure(col, iw, ls, xs)
562
+ if col["label"]:
563
+ lab_h = text_height([[Run(col["label"], True)]], iw, ls, 1.2, "display")
564
+ els.append(El("text", "col_label", ix, cy, iw, lab_h + 2, paras=[[Run(col["label"], True, "accent", f"{side}.label")]],
565
+ size=ls, lh=1.2, font="display"))
566
+ cy += lab_h + xs * 0.9
567
+ for n, (item, hh) in enumerate(zip(col["items"], heights, strict=True)):
568
+ d = xs * 0.32
569
+ els.append(El("circle", "dot", ix + 2, cy + xs * 0.69 - d / 2, d, d, fill="accent"))
570
+ els.append(El("text", "item", ix + 40, cy, iw - 40, hh + 2, paras=[[Run(item, False, "ink", f"{side}.items.{n}")]],
571
+ size=xs, lh=1.38))
572
+ cy += hh + xs * 0.75
573
+
574
+
575
+ def _compose_once(s: dict, deck: dict, index: int, theme: str, fmt: str, start: int) -> list[El]:
576
+ from app.slides.themes import THEMES
577
+
578
+ t = TYPE[fmt]
579
+ dark = THEMES[theme].dark
580
+ layout, v = s["layout"], s["variant"]
581
+ els: list[El] = []
582
+
583
+ lead_slot = block(theme, layout, v, "lead")
584
+
585
+ def rect(name: str) -> tuple | None:
586
+ r = block(theme, layout, v, name)
587
+ # No lead: what sits right under the lead's slot (in the same column) moves up into it.
588
+ if r and name in ("body", "left", "right") and lead_slot and not s["lead"]:
589
+ lx, ly, lw, lh_ = lead_slot[:4]
590
+ if r[1] >= ly + lh_ - 2 and r[1] - (ly + lh_) < 80 and r[0] < lx + lw and lx < r[0] + r[2]:
591
+ return (r[0], ly, r[2], r[3] + r[1] - ly, *r[4:])
592
+ return r
593
+
594
+ k = rect("kicker")
595
+ if k and s["kicker"]:
596
+ x, y, w, h, align, *_ = k
597
+ bar_x = x + (w - 64) / 2 if align == "center" else (x + w - 64 if align == "right" else x)
598
+ els.append(El("rect", "bar", bar_x, y - 18, 64, 5, fill="accent"))
599
+ els.append(_text("kicker", k, [[Run(s["kicker"], True, "accent", "kicker")]], t["kicker"], 0, font="mono", lh=1.2,
600
+ upper=True, track=0.16, field_name="kicker"))
601
+
602
+ heading_base = {"title": t["title"], "section": t["section"]}.get(layout, t["heading"])
603
+ hr = rect("heading")
604
+ if hr and s["heading"]:
605
+ els.append(_text("heading", hr, [[Run(s["heading"], True, "ink", "heading")]], heading_base, start, font="display",
606
+ lh=1.08, field_name="heading"))
607
+
608
+ lr = rect("lead")
609
+ if lr and s["lead"]:
610
+ base = t["title_lead"] if layout == "title" else t["lead"]
611
+ head = next((e for e in els if e.role == "heading"), None)
612
+ if head and head.valign == "top" and abs(head.x - lr[0]) < 1 and head.y < lr[1]:
613
+ # Under a top aligned heading in the same column: follow the heading instead of waiting at the box's top.
614
+ top = head.y + head.text_height() + head.size * 0.55
615
+ lr = (lr[0], top, lr[2], lr[1] + lr[3] - top, *lr[4:])
616
+ els.append(_text("lead", lr, [[Run(s["lead"], False, "mute", "lead")]], base, start, lh=1.4, field_name="lead"))
617
+
618
+ if layout == "stat":
619
+ els.append(_text("value", rect("value"), [[Run(s["stat"]["value"], True, "accent", "stat.value")]], t["value"], start,
620
+ font="display", lh=1.0, field_name="stat_value"))
621
+ els.append(_text("label", rect("label"), [[Run(s["stat"]["label"], False, "ink", "stat.label")]], t["label"], start, lh=1.35,
622
+ field_name="stat_label", grow=True))
623
+ if layout == "quote":
624
+ q = rect("quote")
625
+ els.append(_text("quote", q, [[Run("“", True, "accent"), Run(s["quote"]["text"], False, "ink", "quote.text"),
626
+ Run("”", True, "accent")]], t["quote"], start, font="display", lh=1.22,
627
+ field_name="quote", grow=True))
628
+ if s["quote"]["by"] and (b := rect("by")):
629
+ els.append(_text("by", b, [[Run(s["quote"]["by"], False, "mute", "quote.by")]], t["by"], 0, font="mono", lh=1.3,
630
+ upper=True, track=0.08, field_name="by"))
631
+ if layout == "two_column":
632
+ _columns(els, s, (rect("left"), rect("right")), t, start)
633
+ br = rect("body")
634
+ if br:
635
+ _body(els, s, br, br[6], fmt, t, start, dark)
636
+ cr = rect("closing")
637
+ if cr and s["closing"]:
638
+ kind = cr[6]
639
+ if kind == "band":
640
+ x, y, w, h, *_ = cr
641
+ els.append(El("rect", "band", x, y, w, h, fill="band", radius=24))
642
+ inner = (x + 60, y, w - 120, h, "left", "middle")
643
+ els.append(_text("closing", inner, [[Run(s["closing"], True, "bandink", "closing")]], t["closing"], start,
644
+ font="display", lh=1.15, field_name="closing"))
645
+ else:
646
+ base = t["closing_big"] if kind == "big" else t["closing"]
647
+ els.append(_text("closing", cr, [[Run(s["closing"], True, "ink", "closing")]], base, start, font="display", lh=1.12,
648
+ field_name="closing", grow=kind == "big"))
649
+
650
+ if layout != "title":
651
+ total = len(deck["slides"])
652
+ foot = [[Run(clip(deck.get("title"), 70), False, "mute")]]
653
+ els.append(El("text", "footer", 140, 1018, 1200, 30, paras=foot, size=t["footer"], lh=1.2, font="mono"))
654
+ els.append(El("text", "footer", 1480, 1018, 300, 30, paras=[[Run(f"{index + 1:02d} / {total:02d}", False, "mute")]],
655
+ size=t["footer"], lh=1.2, font="mono", align="right"))
656
+ return els
657
+
658
+
659
+ def _shorten(s: dict, err: NoFit, fmt: str) -> bool:
660
+ """Make the text that did not fit shorter (or drop a point). False when there is nothing left to shorten."""
661
+ lo = LIMITS[fmt]["points"][0]
662
+ name = err.field_name
663
+
664
+ def cut(text: str) -> str:
665
+ return clip(text, max(int(len(text) * 0.82), 12)) if len(text) > 14 else text
666
+
667
+ if name == "points":
668
+ pts = s["points"]
669
+ if s["layout"] == "agenda" or len(pts) > max(lo, 3):
670
+ if len(pts) > 2:
671
+ pts.pop()
672
+ return True
673
+ longest = max(range(len(pts)), key=lambda i: len(pts[i]["text"]) + len(pts[i]["term"]), default=None)
674
+ if longest is None or len(pts[longest]["text"]) <= 14:
675
+ return False
676
+ pts[longest]["text"] = cut(pts[longest]["text"])
677
+ return True
678
+ if name == "takeaways":
679
+ tk = s["takeaways"]
680
+ i = max(range(len(tk)), key=lambda j: len(tk[j]), default=None)
681
+ if i is None or len(tk[i]) <= 14:
682
+ return False
683
+ tk[i] = cut(tk[i])
684
+ return True
685
+ if name in ("left_items", "right_items"):
686
+ col = s[name.split("_")[0]]
687
+ if len(col["items"]) > 2:
688
+ col["items"].pop()
689
+ return True
690
+ i = max(range(len(col["items"])), key=lambda j: len(col["items"][j]), default=None)
691
+ if i is None or len(col["items"][i]) <= 14:
692
+ return False
693
+ col["items"][i] = cut(col["items"][i])
694
+ return True
695
+ path = {"stat_value": ("stat", "value"), "stat_label": ("stat", "label"), "quote": ("quote", "text"),
696
+ "by": ("quote", "by")}.get(name)
697
+ if path:
698
+ holder, key = s[path[0]], path[1]
699
+ elif name in s and isinstance(s[name], str):
700
+ holder, key = s, name
701
+ else:
702
+ return False
703
+ if len(holder[key]) <= 14:
704
+ if name in ("lead", "by", "kicker"):
705
+ holder[key] = ""
706
+ return True
707
+ return False
708
+ holder[key] = cut(holder[key])
709
+ return True
710
+
711
+
712
+ def compose(deck: dict, index: int, theme: str, fmt: str, seed: int = 0) -> Composed:
713
+ """Slide `index` of a clamped deck, placed. Text that does not fit is shortened until it does; a slide that
714
+ still cannot be drawn becomes a plain section slide with its heading."""
715
+ theme, fmt = theme_id(theme), fmt if fmt in TYPE else "detailed"
716
+ s = copy.deepcopy(deck["slides"][index])
717
+ if s["variant"] not in VARIANTS[s["layout"]]:
718
+ s["variant"] = VARIANTS[s["layout"]][0]
719
+ start = min(int(s.get("shrink") or 0), len(SCALES) - 1)
720
+ for _ in range(60):
721
+ try:
722
+ return Composed(s, _compose_once(s, deck, index, theme, fmt, start), seed)
723
+ except NoFit as err:
724
+ if not _shorten(s, err, fmt):
725
+ break
726
+ plain = {**copy.deepcopy(s), "layout": "section", "variant": VARIANTS["section"][0], "lead": "", "points": [],
727
+ "heading": clip(s["heading"] or deck.get("title"), 60)}
728
+ return Composed(plain, _compose_once(plain, deck, index, theme, fmt, 0), seed)
729
+
730
+
731
+ def seed_of(value: object) -> int:
732
+ """A stable integer seed from a deck id."""
733
+ digits = re.sub(r"[^0-9a-f]", "", str(value or "").lower())
734
+ return int(digits[:12] or "0", 16)
backend/app/slides/metrics.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Advance widths (in em) of the characters slides use most, measured in Chrome with the fonts the slides load
2
+ (Space Grotesk bold for headings, Inter regular and bold for text). Text is measured with these before it is placed,
3
+ so a line breaks where the browser will break it. Regenerate if the fonts change: draw each character ten times
4
+ between two x's at 100px in headless Chrome and subtract the width of "xx"."""
5
+
6
+ CHARS = "".join(chr(c) for c in range(32, 127)) + "\u2026\u201c\u201d\u2018\u2019\u2013\u2014\u00e9"
7
+
8
+ _DISPLAY_BOLD = (
9
+ 0.254, 0.298, 0.514, 0.636, 0.606, 0.758, 0.591, 0.294, 0.398, 0.390, 0.540, 0.620, 0.294, 0.425, 0.298, 0.388,
10
+ 0.648, 0.452, 0.594, 0.608, 0.636, 0.600, 0.618, 0.554, 0.600, 0.618, 0.298, 0.298, 0.620, 0.620, 0.620, 0.578,
11
+ 1.014, 0.634, 0.664, 0.644, 0.646, 0.554, 0.534, 0.662, 0.656, 0.264, 0.578, 0.626, 0.542, 0.882, 0.670, 0.676,
12
+ 0.604, 0.676, 0.632, 0.606, 0.580, 0.672, 0.616, 0.898, 0.644, 0.617, 0.576, 0.358, 0.388, 0.358, 0.620, 0.620,
13
+ 0.296, 0.577, 0.635, 0.580, 0.635, 0.571, 0.337, 0.635, 0.616, 0.266, 0.268, 0.564, 0.266, 0.854, 0.616, 0.606,
14
+ 0.635, 0.635, 0.396, 0.522, 0.391, 0.616, 0.548, 0.784, 0.592, 0.616, 0.518, 0.466, 0.258, 0.466, 0.620, 0.798,
15
+ 0.514, 0.514, 0.294, 0.294, 0.577, 0.881, 0.571,
16
+ )
17
+
18
+ _BODY = (
19
+ 0.281, 0.288, 0.466, 0.633, 0.642, 0.982, 0.644, 0.300, 0.365, 0.365, 0.501, 0.657, 0.288, 0.455, 0.288, 0.360,
20
+ 0.631, 0.407, 0.610, 0.618, 0.646, 0.593, 0.620, 0.584, 0.619, 0.620, 0.288, 0.302, 0.662, 0.655, 0.662, 0.511,
21
+ 0.966, 0.703, 0.654, 0.730, 0.722, 0.601, 0.590, 0.746, 0.743, 0.269, 0.571, 0.672, 0.565, 0.903, 0.753, 0.765,
22
+ 0.639, 0.765, 0.644, 0.642, 0.633, 0.744, 0.703, 1.007, 0.682, 0.679, 0.629, 0.365, 0.316, 0.365, 0.471, 0.456,
23
+ 0.323, 0.562, 0.610, 0.567, 0.610, 0.579, 0.337, 0.611, 0.591, 0.242, 0.242, 0.549, 0.242, 0.876, 0.591, 0.595,
24
+ 0.610, 0.610, 0.392, 0.528, 0.329, 0.591, 0.562, 0.818, 0.546, 0.562, 0.552, 0.426, 0.333, 0.426, 0.657, 0.864,
25
+ 0.440, 0.440, 0.261, 0.261, 0.500, 1.000, 0.579,
26
+ )
27
+
28
+ _BODY_BOLD = (
29
+ 0.237, 0.338, 0.551, 0.649, 0.655, 1.015, 0.672, 0.339, 0.377, 0.377, 0.559, 0.674, 0.334, 0.463, 0.334, 0.388,
30
+ 0.674, 0.431, 0.630, 0.645, 0.676, 0.622, 0.650, 0.599, 0.651, 0.650, 0.334, 0.343, 0.679, 0.672, 0.679, 0.559,
31
+ 1.016, 0.760, 0.662, 0.740, 0.722, 0.607, 0.587, 0.751, 0.747, 0.281, 0.584, 0.719, 0.565, 0.932, 0.762, 0.770,
32
+ 0.648, 0.777, 0.657, 0.655, 0.661, 0.732, 0.760, 1.057, 0.738, 0.731, 0.664, 0.377, 0.344, 0.377, 0.487, 0.476,
33
+ 0.365, 0.580, 0.628, 0.584, 0.628, 0.592, 0.362, 0.630, 0.623, 0.271, 0.271, 0.580, 0.271, 0.912, 0.622, 0.609,
34
+ 0.628, 0.628, 0.415, 0.560, 0.358, 0.623, 0.600, 0.850, 0.580, 0.602, 0.573, 0.469, 0.372, 0.469, 0.674, 1.002,
35
+ 0.540, 0.532, 0.311, 0.311, 0.500, 1.000, 0.592,
36
+ )
37
+
38
+ WIDTHS = {name: dict(zip(CHARS, table, strict=True)) for name, table in
39
+ (("display_bold", _DISPLAY_BOLD), ("body", _BODY), ("body_bold", _BODY_BOLD))}
backend/app/slides/render.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A deck as HTML: one standalone page per slide (shown in the app in a sandboxed frame, scaled to fit) and one page
2
+ with every slide (printed to PDF). Every word from the model is escaped; the markup is all ours."""
3
+
4
+ import math
5
+
6
+ from markupsafe import escape
7
+
8
+ from app.slides.content import field_limit
9
+ from app.slides.decor import Decor, Shape, build, ring_path
10
+ from app.slides.layout import Composed, El, Run, compose
11
+ from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
12
+
13
+ FONT_LINK = ("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;700"
14
+ "&family=JetBrains+Mono:wght@400;700&display=swap")
15
+
16
+ _FAMILY = {"display": f"'{FONTS['display']}','{FONTS['body']}',sans-serif", "body": f"'{FONTS['body']}',sans-serif",
17
+ "mono": f"'{FONTS['mono']}',monospace"}
18
+
19
+
20
+ def color(pal: Palette, key: str | None) -> str:
21
+ if not key:
22
+ return "transparent"
23
+ return "#ffffff" if key == "white" else getattr(pal, key)
24
+
25
+
26
+ CSS = f"""
27
+ html,body{{margin:0;padding:0}}
28
+ .slide{{position:relative;width:{W}px;height:{H}px;overflow:hidden;font-family:{_FAMILY['body']};
29
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}}
30
+ .slide svg.bg{{position:absolute;inset:0}}
31
+ .e{{position:absolute;box-sizing:border-box;margin:0}}
32
+ .t{{display:flex;flex-direction:column;overflow:visible;overflow-wrap:anywhere}}
33
+ .t>.in{{display:block}}
34
+ .t.v-middle{{justify-content:center}}.t.v-bottom{{justify-content:flex-end}}
35
+ .t p{{margin:0}}
36
+ .t b{{font-weight:700}}
37
+ .r-heading,.r-quote,.r-closing,.r-term{{text-wrap:balance}}
38
+ .dark .r-heading.l-title,.dark .r-value{{text-shadow:0 0 48px rgba(33,124,255,.45)}}
39
+ .dark .r-marker{{box-shadow:0 0 36px rgba(33,124,255,.75)}}
40
+ .dark .r-bar{{box-shadow:0 0 18px rgba(77,148,255,.8)}}
41
+ .light .r-card,.light .r-panel{{box-shadow:0 18px 44px rgba(11,26,58,.14)}}
42
+ .light .r-band{{box-shadow:0 18px 44px rgba(11,26,58,.26)}}
43
+ """
44
+
45
+
46
+ def _style(**kw) -> str:
47
+ return ";".join(f"{k.replace('_', '-')}:{v}" for k, v in kw.items() if v is not None)
48
+
49
+
50
+ def _el(e: El, pal: Palette, layout: str, fmt: str) -> str:
51
+ box = dict(left=f"{e.x:.1f}px", top=f"{e.y:.1f}px", width=f"{e.w:.1f}px", height=f"{e.h:.1f}px")
52
+ if e.kind == "text":
53
+ paras = []
54
+ for i, p in enumerate(e.paras):
55
+ runs = "".join(
56
+ f'<span{_editable(r, layout, fmt)} style="color:{color(pal, r.color)}{";font-weight:700" if r.bold else ""}">'
57
+ f'{escape(r.text)}</span>' for r in p)
58
+ gap = f' style="margin-top:{e.gap:.1f}px"' if i and e.gap else ""
59
+ paras.append(f"<p{gap}>{runs}</p>")
60
+ css = _style(**box, font_size=f"{e.size}px", line_height=str(e.lh), font_family=_FAMILY[e.font],
61
+ text_align=e.align, letter_spacing=f"{e.track}em" if e.track else None,
62
+ text_transform="uppercase" if e.upper else None,
63
+ font_weight="700" if e.font == "display" else None)
64
+ return f'<div class="e t r-{e.role} l-{layout} v-{e.valign}" style="{css}"><div class="in">{"".join(paras)}</div></div>'
65
+ if e.kind == "line":
66
+ horizontal = e.h == 0
67
+ css = _style(left=f"{e.x:.1f}px", top=f"{e.y - (e.stroke_w / 2 if horizontal else 0):.1f}px",
68
+ width=f"{e.w if horizontal else e.stroke_w:.1f}px", height=f"{e.stroke_w if horizontal else e.h:.1f}px",
69
+ background=color(pal, e.stroke), border_radius="2px")
70
+ return f'<div class="e r-{e.role}" style="{css}"></div>'
71
+ radius = "50%" if e.kind == "circle" else f"{e.radius}px"
72
+ border = f"{e.stroke_w}px solid {color(pal, e.stroke)}" if e.stroke and e.stroke_w else None
73
+ css = _style(**box, background=color(pal, e.fill), border=border, border_radius=radius,
74
+ opacity=str(e.opacity) if e.opacity < 1 else None)
75
+ return f'<div class="e r-{e.role}" style="{css}"></div>'
76
+
77
+
78
+ def _editable(r: Run, layout: str, fmt: str) -> str:
79
+ """Where a run's text lives and how long it may be, for the page's editor (none on our own text: numbers, footers)."""
80
+ return f' data-f="{r.field}" data-max="{field_limit(layout, r.field, fmt)}"' if r.field else ""
81
+
82
+
83
+ def _svg(d: Decor, pal: Palette, dark: bool, uid: str) -> str:
84
+ defs, out = [], []
85
+ for n, s in enumerate(d.shapes):
86
+ out.append(_shape(s, pal, dark, f"{uid}{n}", defs))
87
+ hx, hy = d.hot
88
+ sky = (f'<radialGradient id="{uid}sky" gradientUnits="userSpaceOnUse" cx="{hx:.0f}" cy="{hy:.0f}" r="{W * 0.85:.0f}">'
89
+ f'<stop offset="0" stop-color="{pal.bg2}"/><stop offset="1" stop-color="{pal.bg1}"/></radialGradient>')
90
+ return (f'<svg class="bg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><defs>{sky}{"".join(defs)}</defs>'
91
+ f'<rect width="{W}" height="{H}" fill="url(#{uid}sky)"/>{"".join(out)}</svg>')
92
+
93
+
94
+ def _shape(s: Shape, pal: Palette, dark: bool, gid: str, defs: list[str]) -> str:
95
+ c = color(pal, s.color)
96
+ if s.kind == "stars":
97
+ return "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{o}"/>' for x, y, r, o in s.points)
98
+ if s.kind == "glow":
99
+ defs.append(f'<radialGradient id="{gid}"><stop offset="0" stop-color="{c}" stop-opacity="{s.opacity}"/>'
100
+ f'<stop offset="1" stop-color="{c}" stop-opacity="0"/></radialGradient>')
101
+ return f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
102
+ if s.kind == "planet":
103
+ hi, lo, rim = planet_colors(pal, dark)
104
+ defs.append(f'<radialGradient id="{gid}" cx="35%" cy="28%" r="80%"><stop offset="0" stop-color="{hi}"/>'
105
+ f'<stop offset="1" stop-color="{lo}"/></radialGradient>')
106
+ return (f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
107
+ f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="none" stroke="{rim}" stroke-width="3" opacity=".7"/>')
108
+ if s.kind == "band":
109
+ lo, hi = ring_shade(s)
110
+ defs.append(f'<linearGradient id="{gid}" gradientUnits="userSpaceOnUse" x1="{lo:.0f}" y1="0" x2="{hi:.0f}" y2="0">'
111
+ + "".join(f'<stop offset="{o}" stop-color="{c}" stop-opacity="{a * s.opacity:.3f}"/>' for o, a in SHADE)
112
+ + "</linearGradient>")
113
+ pts = ring_path(s) + ring_path(s, s.inner)[::-1]
114
+ return f'<path d="M{_pts(pts)}Z" fill="url(#{gid})"/>'
115
+ if s.kind == "ring" and s.arc:
116
+ return (f'<path d="M{_pts(ring_path(s))}" fill="none" stroke="{c}" stroke-width="{s.width}" '
117
+ f'stroke-linecap="round" opacity="{s.opacity}"/>')
118
+ if s.kind == "ring":
119
+ return (f'<ellipse cx="{s.cx:.0f}" cy="{s.cy:.0f}" rx="{s.rx:.0f}" ry="{s.ry:.0f}" fill="none" stroke="{c}" '
120
+ f'stroke-width="{s.width}" opacity="{s.opacity}" transform="rotate({s.rot:.1f} {s.cx:.0f} {s.cy:.0f})"/>')
121
+ if s.kind == "const":
122
+ lines = "".join(f'<line x1="{s.points[a][0]:.0f}" y1="{s.points[a][1]:.0f}" x2="{s.points[b][0]:.0f}" '
123
+ f'y2="{s.points[b][1]:.0f}" stroke="{c}" stroke-width="1.6" opacity="{s.opacity * 0.5:.2f}"/>'
124
+ for a, b in s.edges)
125
+ dots = "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{s.opacity}"/>' for x, y, r, _ in s.points)
126
+ return lines + dots
127
+ return ""
128
+
129
+
130
+ # How brightly a ringed planet's band shows across its width, left to right: lit from the upper left, as the planet is.
131
+ SHADE = ((0, 1.0), (0.45, 0.8), (1, 0.3))
132
+
133
+
134
+ def ring_shade(s: Shape) -> tuple[float, float]:
135
+ """The band's left and right extent on the slide, which SHADE runs across."""
136
+ half = math.hypot(s.rx * math.cos(math.radians(s.rot)), s.ry * math.sin(math.radians(s.rot)))
137
+ return s.cx - half, s.cx + half
138
+
139
+
140
+ def _pts(pts: list[tuple[float, float]]) -> str:
141
+ return "L".join(f"{x:.1f} {y:.1f}" for x, y in pts)
142
+
143
+
144
+ def planet_colors(pal: Palette, dark: bool) -> tuple[str, str, str]:
145
+ """Lit side, dark side and rim of a planet."""
146
+ return ("#1d4fb3", "#020713", pal.accent) if dark else ("#5d9bff", pal.accent2, pal.accent)
147
+
148
+
149
+ def slide_body(c: Composed, deck: dict, index: int, theme: str, fmt: str) -> str:
150
+ t = THEMES[theme]
151
+ s = c.slide
152
+ d = build(theme, s["layout"], s["variant"], c.seed, index)
153
+ els = "".join(_el(e, t.pal, s["layout"], fmt) for e in c.elements)
154
+ cls = f"slide {'dark' if t.dark else 'light'} l-{s['layout']} v-{s['variant']}"
155
+ return f'<div class="{cls}" style="background:{t.pal.bg1}">{_svg(d, t.pal, t.dark, f"s{index}g")}{els}</div>'
156
+
157
+
158
+ def _page(body: str, extra_css: str = "") -> str:
159
+ return (f'<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="{FONT_LINK}">'
160
+ f"<style>{CSS}{extra_css}</style></head><body>{body}</body></html>")
161
+
162
+
163
+ def slide_html(deck: dict, index: int, theme: str, fmt: str, seed: int) -> str:
164
+ """One slide as a standalone page."""
165
+ theme = theme_id(theme)
166
+ return _page(slide_body(compose(deck, index, theme, fmt, seed), deck, index, theme, fmt),
167
+ f"body{{background:{THEMES[theme].pal.bg1}}}")
168
+
169
+
170
+ def slides_html(deck: dict, theme: str, fmt: str, seed: int, start: int, end: int) -> str:
171
+ """Slides start..end-1 of the deck stacked on one page, each as it is in the deck (its number, its sky)."""
172
+ theme = theme_id(theme)
173
+ body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme, fmt) for i in range(start, end))
174
+ return _page(body, f"body{{background:{THEMES[theme].pal.bg1}}}")
175
+
176
+
177
+ def deck_print_html(deck: dict, theme: str, fmt: str, seed: int, script: str = "") -> str:
178
+ """Every slide on one page, one per printed page."""
179
+ theme = theme_id(theme)
180
+ body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme, fmt) for i in range(len(deck["slides"])))
181
+ css = (f"@page{{size:{W}px {H}px;margin:0}}body{{background:{THEMES[theme].pal.bg1}}}"
182
+ ".slide{break-after:page;page-break-after:always}.slide:last-child{break-after:auto;page-break-after:auto}"
183
+ "*{-webkit-print-color-adjust:exact;print-color-adjust:exact}")
184
+ return _page(body + script, css)
backend/app/slides/themes.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The two deck themes. Both are space themes in the app's own palette (black, #217cff, white): one on a night sky, one
2
+ on a pale daylight sky. Colours are plain hex so the HTML and the PowerPoint use exactly the same values."""
3
+
4
+ from dataclasses import dataclass
5
+
6
+ W, H = 1920, 1080 # every slide is drawn at this size
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Palette:
11
+ bg1: str # sky at the edges
12
+ bg2: str # sky at its brightest point
13
+ ink: str # headings and body text
14
+ mute: str # secondary text
15
+ accent: str # kickers, numbers, markers
16
+ accent2: str # second accent (planets, rings)
17
+ card: str # card and panel fill
18
+ cardline: str # card and panel border
19
+ line: str # rules and dividers
20
+ band: str # the closing band
21
+ bandink: str # text on the band
22
+ star: str # star colour
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Theme:
27
+ id: str
28
+ name: str
29
+ dark: bool
30
+ pal: Palette
31
+
32
+
33
+ THEMES: dict[str, Theme] = {t.id: t for t in (
34
+ Theme("dark-space", "Night stellar", True, Palette(
35
+ bg1="#000000", bg2="#0a1736", ink="#ffffff", mute="#b4bccc", accent="#4d94ff", accent2="#217cff",
36
+ card="#0b1326", cardline="#1f3156", line="#26385c", band="#0e1c3d", bandink="#ffffff", star="#ffffff")),
37
+ Theme("light-space", "Moon light", False, Palette(
38
+ bg1="#c3cddf", bg2="#dfe5ef", ink="#0b1a3a", mute="#36435f", accent="#1858c4", accent2="#0b3d91",
39
+ card="#e9eef6", cardline="#b3bfd5", line="#a7b4cc", band="#0b1a3a", bandink="#ffffff", star="#0b1a3a")),
40
+ )}
41
+ DEFAULT_THEME = "dark-space"
42
+
43
+ FONTS = {"display": "Space Grotesk", "body": "Inter", "mono": "JetBrains Mono"}
44
+
45
+
46
+ def theme_id(value: object) -> str:
47
+ return value if isinstance(value, str) and value in THEMES else DEFAULT_THEME
backend/app/worker.py CHANGED
@@ -45,7 +45,7 @@ from app.models import (
45
  Workspace,
46
  WorkspaceMember,
47
  )
48
- from app.pipeline import flashcards, generate, infographic, launchkit, media, quiz, report
49
  from app.pipeline.generate import NothingToDo
50
  from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
51
  from app.pipeline.memory import learn_from_message
@@ -295,6 +295,7 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
295
  "flashcards": _artifact_handler(flashcards.build_flashcards, "Flashcards ready"),
296
  "report": _artifact_handler(report.build_report, "Report ready"),
297
  "infographic": _artifact_handler(infographic.build_infographic, "Infographic ready"),
 
298
  "report_block": report.handle_report_block,
299
  "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
300
  "video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
@@ -338,9 +339,10 @@ def run_job(
338
  mark_artifact_failed(db, job, str(exc))
339
  return
340
  except Exception as exc:
341
- log.exception("job %s (%s) attempt %s failed", job.id, job.kind, job.attempts)
342
  db.rollback()
343
  job = db.get(Job, job_id)
 
344
  if not retry_or_fail(db, job, f"{type(exc).__name__}: {exc}"):
345
  mark_artifact_failed(db, job, f"{type(exc).__name__}: {exc}")
346
  return
@@ -489,6 +491,15 @@ def run_periodic(now: float) -> None:
489
  # Main loop
490
 
491
 
 
 
 
 
 
 
 
 
 
492
  def run_loop(stop: threading.Event, worker_id: str | None = None) -> None:
493
  """Claim and run jobs until `stop` is set. Used by the CLI and, in development, by the API."""
494
  worker_id = worker_id or f"{socket.gethostname()}:{os.getpid()}"
@@ -509,7 +520,9 @@ def run_loop(stop: threading.Event, worker_id: str | None = None) -> None:
509
  break
510
  if not job_id:
511
  break
512
- running.add(pool.submit(run_job, job_id))
 
 
513
  claimed = True
514
  if not claimed:
515
  stop.wait(settings.worker_poll_seconds)
 
45
  Workspace,
46
  WorkspaceMember,
47
  )
48
+ from app.pipeline import flashcards, generate, infographic, launchkit, media, quiz, report, slides
49
  from app.pipeline.generate import NothingToDo
50
  from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
51
  from app.pipeline.memory import learn_from_message
 
295
  "flashcards": _artifact_handler(flashcards.build_flashcards, "Flashcards ready"),
296
  "report": _artifact_handler(report.build_report, "Report ready"),
297
  "infographic": _artifact_handler(infographic.build_infographic, "Infographic ready"),
298
+ "slide_deck": _artifact_handler(slides.build_slide_deck, "Slide deck ready"),
299
  "report_block": report.handle_report_block,
300
  "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
301
  "video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
 
339
  mark_artifact_failed(db, job, str(exc))
340
  return
341
  except Exception as exc:
342
+ # Roll back first: after a failed flush, touching `job` raises PendingRollbackError.
343
  db.rollback()
344
  job = db.get(Job, job_id)
345
+ log.exception("job %s (%s) attempt %s failed", job_id, job.kind, job.attempts)
346
  if not retry_or_fail(db, job, f"{type(exc).__name__}: {exc}"):
347
  mark_artifact_failed(db, job, f"{type(exc).__name__}: {exc}")
348
  return
 
491
  # Main loop
492
 
493
 
494
+ def _log_crash(job_id: uuid.UUID) -> Callable[[Future], None]:
495
+ """run_job handles job errors itself; anything escaping it would otherwise vanish inside the Future and leave
496
+ the job 'running' until recover_stale."""
497
+ def callback(future: Future) -> None:
498
+ if exc := future.exception():
499
+ log.error("job %s crashed outside its error handling", job_id, exc_info=exc)
500
+ return callback
501
+
502
+
503
  def run_loop(stop: threading.Event, worker_id: str | None = None) -> None:
504
  """Claim and run jobs until `stop` is set. Used by the CLI and, in development, by the API."""
505
  worker_id = worker_id or f"{socket.gethostname()}:{os.getpid()}"
 
520
  break
521
  if not job_id:
522
  break
523
+ future = pool.submit(run_job, job_id)
524
+ future.add_done_callback(_log_crash(job_id))
525
+ running.add(future)
526
  claimed = True
527
  if not claimed:
528
  stop.wait(settings.worker_poll_seconds)
backend/requirements.txt CHANGED
@@ -19,6 +19,7 @@ rank-bm25==0.2.*
19
  feedparser==6.*
20
  beautifulsoup4==4.*
21
  pypdf==5.*
 
22
  stripe==13.*
23
  jinja2==3.*
24
 
 
19
  feedparser==6.*
20
  beautifulsoup4==4.*
21
  pypdf==5.*
22
+ python-pptx==1.*
23
  stripe==13.*
24
  jinja2==3.*
25
 
backend/scripts/render_slide_thumbnails.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Draws a sample opening slide in each slide deck theme with a local Chrome, for the theme picker's thumbnails.
2
+
3
+ python scripts/render_slide_thumbnails.py # writes ../frontend/public/slides/<theme>.png
4
+ Set CHROME to the browser binary if it is not found (see app.infographics.image.chrome)."""
5
+
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12
+
13
+ from PIL import Image # noqa: E402
14
+
15
+ from app.infographics.image import chrome # noqa: E402
16
+ from app.slides.content import clamp_deck # noqa: E402
17
+ from app.slides.render import slide_html # noqa: E402
18
+ from app.slides.themes import THEMES # noqa: E402
19
+
20
+ SAMPLE = {"title": "Your ideas, in orbit", "slides": [
21
+ {"layout": "title", "kicker": "Slide deck", "heading": "Your ideas, in orbit",
22
+ "lead": "Every post you have written, turned into a story worth presenting."},
23
+ ]}
24
+
25
+ PICK = {"dark-space": "b", "light-space": "a"} # each theme's most telling opener
26
+
27
+ if __name__ == "__main__":
28
+ binary = chrome()
29
+ if not binary:
30
+ sys.exit("No Chrome found: set CHROME")
31
+ folder = Path(__file__).resolve().parents[2] / "frontend/public/slides"
32
+ folder.mkdir(parents=True, exist_ok=True)
33
+ deck = clamp_deck(SAMPLE, "presenter")
34
+ for tid in THEMES:
35
+ deck["slides"][0]["variant"] = PICK[tid]
36
+ with tempfile.TemporaryDirectory() as tmp:
37
+ src, shot = Path(tmp) / "s.html", Path(tmp) / "s.png"
38
+ src.write_text(slide_html(deck, 0, tid, "presenter", 2024), encoding="utf-8")
39
+ subprocess.run([binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
40
+ "--force-device-scale-factor=1", f"--user-data-dir={tmp}/p", "--window-size=1920,1080",
41
+ "--virtual-time-budget=8000", f"--screenshot={shot}", f"file://{src}"],
42
+ check=True, capture_output=True, timeout=120)
43
+ Image.open(shot).convert("RGB").resize((800, 450), Image.LANCZOS).save(folder / f"{tid}.png", optimize=True)
44
+ print(folder / f"{tid}.png")
backend/tests/test_buildout.py CHANGED
@@ -447,7 +447,7 @@ def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatc
447
  client.put("/api/voice", json={"host_voices": {"host_a": "voiceA", "host_b": "voiceB"},
448
  "delivery": {"host_b": {"stability": 0.9, "similarity_boost": 0.7,
449
  "style": 0.1, "speed": 1.1}}}, headers=auth)
450
- llm["PodcastScript"] = {"lines": [
451
  {"speaker": "host_a", "text": "Welcome in.", "sources": []},
452
  {"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
453
  "line_end": 13}]},
@@ -754,13 +754,14 @@ def _schedule(client, auth, artifact, platform, account=None):
754
  def test_postable_lists_what_can_be_posted(client, auth, db_session):
755
  ws = db_session.query(Workspace).first()
756
  quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png", quote="Ship it", source="Ada")
757
- _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
758
  _made(db_session, ws, "video", status="processing", provider="blog2video") # not rendered yet
759
  done = _made(db_session, ws, "video", status="processing", provider="blog2video", video_url="https://v/x.mp4")
760
  unrendered = _made(db_session, ws, "video", status="ready", provider="blog2video", b2v_status="generated")
761
  _made(db_session, ws, "summary", summary="Raise prices once a year [1].")
762
  got = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
763
- assert {a["type"] for a in got.values()} == {"quote_card", "video", "summary"} # no audio, no unfinished video
 
764
  assert str(done.id) in got and got[str(quote.id)]["thumb_url"] and got[str(quote.id)]["media"] == "image"
765
  # Made but not rendered: listed, flagged; the rendered one is not.
766
  assert got[str(unrendered.id)]["needs_render"] is True and got[str(done.id)]["needs_render"] is False
@@ -1094,8 +1095,8 @@ def test_audio_overview_hosts_are_picked_from_the_voices(client, auth, run_jobs,
1094
  used = []
1095
  monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
1096
  b"\x00" * 16000)
1097
- llm["PodcastScript"] = {"lines": [{"speaker": "host_a", "text": "Welcome in.", "sources": []},
1098
- {"speaker": "host_b", "text": "Thanks.", "sources": []}]}
1099
  ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
1100
  db_session.add_all([UserSavedVoice(workspace_id=ws_id, voice_id=v, name=v) for v in ("mine-1", "mine-2")])
1101
  db_session.commit()
@@ -1116,6 +1117,59 @@ def test_audio_overview_hosts_are_picked_from_the_voices(client, auth, run_jobs,
1116
  assert voices == {"host_a": settings.elevenlabs_voice_a, "host_b": settings.elevenlabs_voice_b}
1117
 
1118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1119
  def test_tts_retries_rate_limits(monkeypatch):
1120
  from app.config import settings
1121
  from app.services import tts
 
447
  client.put("/api/voice", json={"host_voices": {"host_a": "voiceA", "host_b": "voiceB"},
448
  "delivery": {"host_b": {"stability": 0.9, "similarity_boost": 0.7,
449
  "style": 0.1, "speed": 1.1}}}, headers=auth)
450
+ llm["WriteAudioScript"] = {"turns": [
451
  {"speaker": "host_a", "text": "Welcome in.", "sources": []},
452
  {"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
453
  "line_end": 13}]},
 
754
  def test_postable_lists_what_can_be_posted(client, auth, db_session):
755
  ws = db_session.query(Workspace).first()
756
  quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png", quote="Ship it", source="Ada")
757
+ audio = _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
758
  _made(db_session, ws, "video", status="processing", provider="blog2video") # not rendered yet
759
  done = _made(db_session, ws, "video", status="processing", provider="blog2video", video_url="https://v/x.mp4")
760
  unrendered = _made(db_session, ws, "video", status="ready", provider="blog2video", b2v_status="generated")
761
  _made(db_session, ws, "summary", summary="Raise prices once a year [1].")
762
  got = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
763
+ assert {a["type"] for a in got.values()} == {"quote_card", "video", "summary", "audio_overview"} # no unfinished video
764
+ assert got[str(audio.id)]["media"] == "audio" and got[str(audio.id)]["download_url"] # audio: to download, not post
765
  assert str(done.id) in got and got[str(quote.id)]["thumb_url"] and got[str(quote.id)]["media"] == "image"
766
  # Made but not rendered: listed, flagged; the rendered one is not.
767
  assert got[str(unrendered.id)]["needs_render"] is True and got[str(done.id)]["needs_render"] is False
 
1095
  used = []
1096
  monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
1097
  b"\x00" * 16000)
1098
+ llm["WriteAudioScript"] = {"turns": [{"speaker": "host_a", "text": "Welcome in.", "sources": []},
1099
+ {"speaker": "host_b", "text": "Thanks.", "sources": []}]}
1100
  ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
1101
  db_session.add_all([UserSavedVoice(workspace_id=ws_id, voice_id=v, name=v) for v in ("mine-1", "mine-2")])
1102
  db_session.commit()
 
1117
  assert voices == {"host_a": settings.elevenlabs_voice_a, "host_b": settings.elevenlabs_voice_b}
1118
 
1119
 
1120
+ def test_audio_overview_alternates_two_hosts_or_uses_one(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
1121
+ from app.config import settings
1122
+ from app.services import tts
1123
+
1124
+ connect(client, auth, run_jobs)
1125
+ pricing = next(d for d in docs(client, auth) if d["title"] == "On Pricing")
1126
+ nb = client.post("/api/notebooks", json={"title": "Money", "document_ids": [pricing["id"]]}, headers=auth).json()
1127
+ monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
1128
+ used = []
1129
+ monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
1130
+ b"\x00" * 16000)
1131
+ seen = {}
1132
+
1133
+ def script(**kw):
1134
+ seen.update(kw)
1135
+ return {"turns": [ # host A twice in a row, **markup**, an empty turn: cleaned and made to alternate
1136
+ {"speaker": "host_a", "text": "Welcome in.", "sources": []},
1137
+ {"speaker": "host_a", "text": "Today, **pricing**.", "sources": []},
1138
+ {"speaker": "host_b", "text": "", "sources": []},
1139
+ {"speaker": "host_b", "text": "Prices went up.", "sources": []},
1140
+ {"speaker": "host_b", "text": "And readers stayed.", "sources": []},
1141
+ {"speaker": "host_a", "text": "Exactly.", "sources": []},
1142
+ ]}
1143
+
1144
+ llm["WriteAudioScript"] = script
1145
+ body = {"type": "audio_overview", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "minutes": 3,
1146
+ "format": "critique", "language": "Spanish", "instructions": "- Focus on raising prices"}
1147
+ art = client.post("/api/artifacts/generate", json=body, headers=auth).json()
1148
+ run_jobs()
1149
+ done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
1150
+ assert done["status"] == "ready", done["content"]
1151
+ segs = done["content"]["segments"]
1152
+ assert [(s["speaker"], s["text"]) for s in segs] == [
1153
+ ("host_a", "Welcome in. Today, pricing."), ("host_b", "Prices went up. And readers stayed."), ("host_a", "Exactly.")]
1154
+ assert seen["hosts"] == 2 and seen["target_words"] == 450 and seen["language"] == "Spanish"
1155
+ assert seen["focus"] == "- Focus on raising prices" and seen["style"].startswith("Critique")
1156
+ assert any(pricing["path"] in m for m in seen["material"]) # the picked post is what the script reads
1157
+ voices = done["content"]["voices"]
1158
+ assert used[1][1] == voices["host_b"] != voices["host_a"] == used[0][1]
1159
+
1160
+ used.clear()
1161
+ one = client.post("/api/artifacts/generate", json={**body, "hosts": 1, "format": "brief"}, headers=auth).json()
1162
+ run_jobs()
1163
+ done = client.get(f"/api/artifacts/{one['id']}", headers=auth).json()
1164
+ assert {s["speaker"] for s in done["content"]["segments"]} == {"host_a"} and seen["hosts"] == 1
1165
+ assert list(done["content"]["voices"]) == ["host_a"] and {v for _, v in used} == {done["content"]["voices"]["host_a"]}
1166
+ assert done["content"]["hosts"] == 1 and done["content"]["focus"] == "- Focus on raising prices"
1167
+
1168
+ r = client.post("/api/artifacts/generate", json={**body, "hosts": 1, "format": "debate"}, headers=auth)
1169
+ assert r.status_code == 400 and "two hosts" in r.json()["detail"]
1170
+ assert client.post("/api/artifacts/generate", json={**body, "document_ids": []}, headers=auth).status_code == 400
1171
+
1172
+
1173
  def test_tts_retries_rate_limits(monkeypatch):
1174
  from app.config import settings
1175
  from app.services import tts
backend/tests/test_slides.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Slide decks: what the model writes is clamped to a fixed shape, laid out by code in either theme, checked against
2
+ the sources, and drawn the same way as pages, a PDF and an editable PowerPoint. The LLM is faked at app.llm.run."""
3
+
4
+ import copy
5
+ import io
6
+ import re
7
+
8
+ import pytest
9
+ from pptx import Presentation
10
+
11
+ from app.infographics.image import chrome
12
+ from app.slides import export
13
+ from app.slides.build import finish, view
14
+ from app.slides.content import LIMITS, clamp_deck, clamp_slide
15
+ from app.slides.layout import GEOMETRY, VARIANTS, assign_variants, compose
16
+ from app.slides.render import deck_print_html, slide_html
17
+ from tests.test_buildout import _notebook_with_pricing, auth, feed, llm # noqa: F401 (fixtures)
18
+
19
+ LONG = "Words that keep going and going " * 40
20
+
21
+ FULL = {
22
+ "title": {"heading": "Pricing that readers say yes to", "kicker": "From 4 posts", "lead": "What two years taught us."},
23
+ "agenda": {"heading": "What we will cover", "points": [{"text": f"Part {i}"} for i in range(9)]},
24
+ "section": {"heading": "The numbers behind trials", "kicker": "Part two", "lead": "Short trials converted twice as well."},
25
+ "points": {"heading": "Why tiers work", "kicker": "Tiers", "lead": "Readers self-select.",
26
+ "points": [{"term": "Anchoring", "text": "A higher tier makes the middle one feel fair."}] * 4},
27
+ "two_column": {"heading": "Monthly or annual", "left": {"label": "Monthly", "items": ["Low commitment", "Easy to leave"]},
28
+ "right": {"label": "Annual", "items": ["Two months free", "Lower churn"]}},
29
+ "stat": {"heading": "Short trials win", "stat": {"value": "2x", "label": "more paid signups"}, "lead": "A decision point."},
30
+ "quote": {"heading": "In their words", "quote": {"text": "Thursday would feel empty without it.", "by": "A reader"}},
31
+ "closing": {"heading": "Key takeaways", "takeaways": ["One.", "Two.", "Three."], "closing": "Price the habit."},
32
+ }
33
+
34
+
35
+ def _stuff(slide: dict, text: str) -> dict:
36
+ """The same slide with every text field set to `text` (the longest anything could be)."""
37
+ s = copy.deepcopy(slide)
38
+ for k in ("heading", "kicker", "lead", "closing"):
39
+ if k in s:
40
+ s[k] = text
41
+ for p in s.get("points") or []:
42
+ p["text"], p["term"] = text, text[:60]
43
+ for side in ("left", "right"):
44
+ if side in s:
45
+ s[side] = {"label": text, "items": [text] * 6}
46
+ if "stat" in s:
47
+ s["stat"] = {"value": "123456789012345678", "label": text}
48
+ if "quote" in s:
49
+ s["quote"] = {"text": text, "by": text}
50
+ if "takeaways" in s:
51
+ s["takeaways"] = [text] * 5
52
+ return s
53
+
54
+
55
+ @pytest.mark.parametrize("theme", ["dark-space", "light-space"])
56
+ @pytest.mark.parametrize("fmt", ["detailed", "presenter"])
57
+ def test_every_variant_draws_inside_its_boxes_at_any_length(theme, fmt):
58
+ for layout, variants in VARIANTS.items():
59
+ for v in variants:
60
+ for text in ("Short text here", LONG, "長い日本語のテキスト" * 30, "Supercalifragilistic" * 12):
61
+ raw = {**_stuff(FULL[layout], text), "layout": layout, "variant": v}
62
+ deck = clamp_deck({"title": "Deck", "slides": [raw, raw]}, fmt)
63
+ for i in range(2):
64
+ c = compose(deck, i, theme, fmt, seed=7)
65
+ for e in c.elements:
66
+ assert 0 <= e.x and e.x + e.w <= 1920 + 1 and 0 <= e.y and e.y + e.h <= 1080 + 1, (layout, v, e.role)
67
+ if e.kind == "text":
68
+ assert e.text_height() <= e.h + 1, (layout, v, e.role, text[:20])
69
+
70
+
71
+ def test_clamp_never_lets_bad_input_through():
72
+ garbage = [None, 5, "text", [], {"layout": "nonsense", "heading": "<script>alert(1)</script>**Bold** [1]"},
73
+ {"layout": "stat", "heading": "No number", "stat": {"value": "", "label": "x"}},
74
+ {"layout": "quote", "heading": "No quote", "quote": None, "points": [{"text": "a"}, {"text": "b"}]},
75
+ {"layout": "two_column", "heading": "One side", "left": {"items": ["x"]}, "right": None},
76
+ {"layout": "points", "heading": "Nul\x00 byte— dash", "points": "not a list"},
77
+ {"layout": "closing", "heading": "", "takeaways": None, "closing": None},
78
+ {"layout": "points", "heading": "x" * 5000, "points": [{"term": None, "text": {"nested": 1}}] * 30}]
79
+ deck = clamp_deck({"title": None, "slides": garbage}, "detailed")
80
+ assert deck and deck["title"]
81
+ keys = set(clamp_slide({"heading": "x"}, "detailed"))
82
+ for s in deck["slides"]:
83
+ assert set(s) == keys # every slide has every key
84
+ for value in (s["heading"], s["lead"], s["kicker"]):
85
+ assert "<" not in value and "\x00" not in value and "—" not in value and "**" not in value
86
+ assert len(s["heading"]) <= LIMITS["detailed"]["heading"]
87
+ layouts = [s["layout"] for s in deck["slides"]]
88
+ assert layouts[0] == "points" or layouts[0] == "section" # unknown layout becomes a simpler one
89
+ assert "stat" not in layouts and "quote" not in layouts and "two_column" not in layouts
90
+ assert clamp_deck({"slides": []}, "detailed") is None and clamp_deck("nope", "presenter") is None
91
+
92
+
93
+ def test_pages_escape_everything_the_model_wrote():
94
+ raw = {"layout": "points", "heading": "Fine <b onmouseover=x>heading</b>", "lead": "a & b < c",
95
+ "points": [{"term": "<img src=x>", "text": "\"quoted\" <script>x</script> text"}] * 3}
96
+ deck = clamp_deck({"title": "<title>", "slides": [raw]}, "detailed")
97
+ deck["slides"][0]["variant"] = "a"
98
+ html = slide_html(deck, 0, "dark-space", "detailed", 1)
99
+ assert "<script" not in html and "<img" not in html and "onmouseover" not in html
100
+ assert "a &amp; b &lt; c" in html
101
+
102
+
103
+ def test_variants_never_repeat_in_a_row_and_differ_between_decks():
104
+ slides = [clamp_slide({**FULL[k], "layout": k}, "detailed") for k in
105
+ ("title", "agenda", "points", "points", "points", "stat", "points", "quote", "points", "closing")]
106
+ a, b = copy.deepcopy(slides), copy.deepcopy(slides)
107
+ assign_variants(a, "dark-space", seed=1)
108
+ pairs = [(s["layout"], s["variant"]) for s in a]
109
+ assert all(pairs[i] != pairs[i + 1] for i in range(len(pairs) - 1))
110
+ points = [s["variant"] for s in a if s["layout"] == "points"]
111
+ assert len(set(points)) == len(VARIANTS["points"]) # every points variant used before any repeats
112
+ assign_variants(b, "dark-space", seed=2, previous=pairs)
113
+ assert [s["variant"] for s in b] != [s["variant"] for s in a]
114
+ assert b[0]["variant"] != a[0]["variant"] and b[-1]["variant"] != a[-1]["variant"] # new opener and closer
115
+ again = copy.deepcopy(slides)
116
+ assign_variants(again, "dark-space", seed=1)
117
+ assert [s["variant"] for s in again] == [s["variant"] for s in a] # one deck always draws the same
118
+
119
+
120
+ def test_the_two_themes_compose_differently():
121
+ for layout, variants in VARIANTS.items():
122
+ for v in variants:
123
+ assert GEOMETRY["dark-space"][layout][v] != GEOMETRY["light-space"][layout][v] or layout in ("points", "two_column"), \
124
+ (layout, v)
125
+
126
+
127
+ def _deck(fmt: str = "detailed") -> dict:
128
+ src = [{"path": "p.md", "line_start": 1, "line_end": 2, "title": "On Pricing"}]
129
+ slides = [{**FULL[k], "layout": k, "notes": f"Say {k}", "sources": src}
130
+ for k in ("title", "agenda", "points", "two_column", "stat", "quote", "closing")]
131
+ deck = clamp_deck({"title": "Pricing", "slides": slides}, fmt)
132
+ assign_variants(deck["slides"], "light-space", seed=3)
133
+ return deck
134
+
135
+
136
+ def test_powerpoint_has_every_slide_as_editable_text_with_notes():
137
+ deck = _deck()
138
+ data = export.build_pptx(deck, "light-space", "detailed", 3)
139
+ prs = Presentation(io.BytesIO(data))
140
+ assert len(prs.slides) == len(deck["slides"]) and prs.slide_width == 12192000
141
+ texts = [" ".join(sh.text_frame.text for sh in s.shapes if sh.has_text_frame) for s in prs.slides]
142
+ assert "Why tiers work" in texts[2] and "Anchoring" in texts[2] and "middle one feel fair" in texts[2]
143
+ notes = prs.slides[2].notes_slide.notes_text_frame.text
144
+ assert "Say points" in notes and "On Pricing (lines 1-2)" in notes
145
+
146
+
147
+ @pytest.mark.skipif(not chrome(), reason="no Chrome to print with")
148
+ def test_pdf_has_one_page_per_slide_and_nothing_overflows():
149
+ from pypdf import PdfReader
150
+
151
+ deck = finish(_deck(), "dark-space", "detailed", 3)
152
+ assert export.overflowing(deck, "dark-space", "detailed", 3) == set()
153
+ pdf = PdfReader(io.BytesIO(export.render_pdf(deck, "dark-space", "detailed", 3)))
154
+ assert len(pdf.pages) == len(deck["slides"])
155
+ assert round(float(pdf.pages[0].mediabox.width) / float(pdf.pages[0].mediabox.height), 2) == round(1920 / 1080, 2)
156
+
157
+
158
+ def test_a_stored_deck_with_a_broken_slide_still_shows():
159
+ content = {"theme": "dark-space", "format": "detailed", "seed": "abc", "deck": _deck()}
160
+ content["deck"]["slides"][2]["variant"] = "zz" # unknown: drawn with the first variant
161
+ content["deck"]["slides"][3]["points"] = None
162
+ pages = view(content)
163
+ assert len(pages) == len(content["deck"]["slides"]) and all(p.startswith("<!doctype html>") for p in pages)
164
+ assert view({"deck": {"slides": []}}) == [] and view({}) == []
165
+ assert "Pricing" in deck_print_html(clamp_deck(content["deck"], "detailed"), "x", "detailed", 1)
166
+
167
+
168
+ # --- the whole flow, through the API ------------------------------------------------------------------------------
169
+
170
+ @pytest.fixture()
171
+ def no_chrome(monkeypatch):
172
+ """Generation measures slides in Chrome when one is installed; the API tests run without it."""
173
+ monkeypatch.setattr(export, "chrome", lambda: None)
174
+
175
+
176
+ def _fake_many(monkeypatch, pricing_path: str, log: dict):
177
+ from app.llm import run
178
+
179
+ def predict_many(signature, inputs_list, *, db, workspace_id, lm=None, workers=5):
180
+ name = signature.__name__
181
+ log.setdefault(name, []).extend(inputs_list)
182
+ out = []
183
+ for inp in inputs_list:
184
+ if name == "WriteSlide":
185
+ good = {"path": pricing_path, "line_start": 1, "line_end": 12}
186
+ fake = {"path": "sources/made-up.md", "line_start": 1, "line_end": 3}
187
+ slide = {"kicker": "Pricing", "heading": inp["heading"], "lead": "Charging more made readers take it seriously.",
188
+ "points": [{"term": "Price", "text": "The paid tier doubled after the price went up."},
189
+ {"term": "Invented", "text": "Ninety percent of readers loved it."},
190
+ {"term": "Seriousness", "text": "Readers took the work seriously."}],
191
+ "notes": "Talk about pricing.", "sources": [good, fake]}
192
+ if inp["layout"] == "stat":
193
+ slide["stat"] = {"value": "90%", "label": "of readers upgraded"} # not in the post: removed
194
+ if inp["layout"] == "quote":
195
+ slide["quote"] = {"text": "Charging more made my readers take the work seriously.", "by": "Ada"}
196
+ if inp["layout"] == "two_column":
197
+ slide["left"] = {"label": "Before", "items": ["Low price", "Few paid readers"]}
198
+ slide["right"] = {"label": "After", "items": ["Ten dollars", "Paid tier doubled"]}
199
+ out.append({"slide": slide})
200
+ elif name == "CheckSlide":
201
+ bad = [c for c in inp["claims"] if "Ninety percent" in c]
202
+ out.append({"checks": [{"n": int(c.split(".")[0]), "supported": False, "fix": ""} for c in bad]})
203
+ else:
204
+ out.append(None)
205
+ return out
206
+
207
+ monkeypatch.setattr(run, "predict_many", predict_many)
208
+
209
+
210
+ def _outline(n: int, layouts=("points", "quote", "stat", "points", "section", "points", "points", "two_column", "points")):
211
+ def make(**inputs):
212
+ return {"outline": {"title": "Pricing for writers", "subtitle": "Why charging more worked",
213
+ "opening_kicker": "Pricing", "agenda_heading": "What we cover",
214
+ "slides": [{"layout": layouts[i % len(layouts)], "heading": f"Slide idea {i}",
215
+ "purpose": "Make a point", "sources": []} for i in range(n)]}}
216
+ return make
217
+
218
+
219
+ CLOSING = {"closing": {"kicker": "Conclusion", "heading": "Key takeaways",
220
+ "takeaways": ["Charge more.", "Readers take it seriously.", "The paid tier grows."],
221
+ "closing": "Price the work like it matters.", "notes": "Close."}}
222
+
223
+
224
+ def test_short_detailed_deck_is_grounded_checked_and_downloadable(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
225
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
226
+ log: dict = {}
227
+ _fake_many(monkeypatch, pricing["path"], log)
228
+ llm["OutlineDeck"] = _outline(9) # too many for a short deck: trimmed
229
+ llm["WriteClosing"] = CLOSING
230
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
231
+ "document_ids": [pricing["id"]], "deck_format": "detailed", "deck_length": "short",
232
+ "theme": "light-space", "instructions": "For new writers"}, headers=auth).json()
233
+ assert art["type_label"] == "Slide deck"
234
+ run_jobs()
235
+ done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
236
+ assert done["status"] == "ready", done["content"].get("error")
237
+ c = done["content"]
238
+ slides = c["deck"]["slides"]
239
+ assert 6 <= len(slides) <= 8 and len(done["content"]["slides_html"]) == len(slides)
240
+ assert slides[0]["layout"] == "title" and slides[-1]["layout"] == "closing"
241
+ assert slides[-1]["closing"] == "Price the work like it matters." and len(slides[-1]["takeaways"]) == 3
242
+ assert not any(s["layout"] == "agenda" for s in slides) # short decks have no agenda
243
+ assert c["theme"] == "light-space" and c["format"] == "detailed"
244
+ body = [s for s in slides[1:-1]]
245
+ for s in body:
246
+ assert all(src["path"] == pricing["path"] for src in s["sources"]) # the invented path is gone
247
+ assert all("Ninety percent" not in p["text"] for p in s["points"]) # unsupported claim removed
248
+ assert s["layout"] != "stat" # its number was not in the post
249
+ assert any(s["layout"] == "quote" for s in body) # the quote is in the post word for word
250
+ pairs = [(s["layout"], s["variant"]) for s in slides]
251
+ assert all(pairs[i] != pairs[i + 1] for i in range(len(pairs) - 1))
252
+ assert all(log["WriteSlide"][0]["source_lines"]) and log["WriteSlide"][0]["deck_format"] == "detailed"
253
+ html = "".join(c["slides_html"])
254
+ assert "<script" not in html and "Pricing for writers" in html
255
+
256
+ pptx = client.get(f"/api/artifacts/{art['id']}/slides.pptx", headers=auth)
257
+ assert pptx.status_code == 200 and "Pricing for writers.pptx" in pptx.headers["content-disposition"]
258
+ assert len(Presentation(io.BytesIO(pptx.content)).slides) == len(slides)
259
+ assert client.get(f"/api/artifacts/{art['id']}/slides.pdf", headers=auth).status_code == 503 # no Chrome here
260
+ assert client.get(f"/api/artifacts/{art['id']}/slides.doc", headers=auth).status_code == 422
261
+
262
+
263
+ def test_default_presenter_deck_has_an_agenda_and_stays_in_range(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
264
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
265
+ log: dict = {}
266
+ _fake_many(monkeypatch, pricing["path"], log)
267
+ llm["OutlineDeck"] = _outline(7)
268
+ llm["WriteClosing"] = CLOSING
269
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
270
+ "document_ids": [pricing["id"]], "deck_format": "presenter", "deck_length": "default",
271
+ "language": "Spanish"}, headers=auth).json()
272
+ run_jobs()
273
+ done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
274
+ slides = done["content"]["deck"]["slides"]
275
+ assert done["status"] == "ready" and 9 <= len(slides) <= 11
276
+ assert slides[1]["layout"] == "agenda" and [p["text"] for p in slides[1]["points"]][:2] == ["Slide idea 0", "Slide idea 1"]
277
+ assert done["content"]["theme"] == "dark-space" # the default
278
+ assert log["WriteSlide"][0]["language"] == "Spanish" and log["WriteSlide"][0]["deck_format"] == "presenter"
279
+ for s in slides:
280
+ assert all(len(p["text"]) <= LIMITS["presenter"]["text"] for p in s["points"] if s["layout"] != "agenda")
281
+
282
+
283
+ def test_too_little_material_fails_cleanly_and_sources_are_required(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
284
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
285
+ _fake_many(monkeypatch, pricing["path"], {})
286
+ llm["OutlineDeck"] = _outline(1)
287
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
288
+ "document_ids": [pricing["id"]], "deck_length": "default"}, headers=auth).json()
289
+ run_jobs()
290
+ failed = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
291
+ assert failed["status"] == "failed" and "Short" in failed["content"]["error"]
292
+ assert llm["_calls"].count("OutlineDeck") >= 2 # asked again before giving up
293
+ assert client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
294
+ "document_ids": []}, headers=auth).status_code == 400
295
+ assert client.get(f"/api/artifacts/{art['id']}/slides.pptx", headers=auth).status_code == 404
296
+
297
+
298
+ def test_a_slide_the_model_fails_to_write_is_written_from_its_sources(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
299
+ from app.llm import run
300
+
301
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
302
+ log: dict = {}
303
+ _fake_many(monkeypatch, pricing["path"], log)
304
+ real = run.predict_many
305
+
306
+ def flaky(signature, inputs_list, **kw):
307
+ out = real(signature, inputs_list, **kw)
308
+ return [None] * len(out) if signature.__name__ == "WriteSlide" else out
309
+
310
+ monkeypatch.setattr(run, "predict_many", flaky)
311
+ llm["OutlineDeck"] = lambda **kw: {"outline": {"title": "Pricing", "subtitle": "", "opening_kicker": "", "agenda_heading": "",
312
+ "slides": [{"layout": "points", "heading": f"Idea {i}", "purpose": "A point",
313
+ "sources": [{"path": pricing["path"], "line_start": 1, "line_end": 14}]}
314
+ for i in range(4)]}}
315
+ llm["WriteClosing"] = lambda **kw: (_ for _ in ()).throw(RuntimeError("model down"))
316
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
317
+ "document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
318
+ run_jobs()
319
+ done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
320
+ assert done["status"] == "ready", done["content"].get("error")
321
+ slides = done["content"]["deck"]["slides"]
322
+ assert [s["heading"] for s in slides[1:-1]] == [f"Idea {i}" for i in range(4)]
323
+ assert slides[-1]["layout"] == "closing" and slides[-1]["takeaways"]
324
+ assert len(log["WriteSlide"]) == 8 # each slide tried twice
325
+ assert re.search(r"Charging more|ten dollars|seriously", " ".join(str(s) for s in slides[1:-1]))
326
+
327
+
328
+ def test_the_opening_and_closing_say_only_what_the_slides_say(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
329
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
330
+ _fake_many(monkeypatch, pricing["path"], {})
331
+ outline = _outline(4)
332
+
333
+ def mixed_up(**kw):
334
+ out = outline(**kw)
335
+ out["outline"]["subtitle"] = "Ada won a writing award for her pricing" # joins two things no slide says
336
+ return out
337
+
338
+ llm["OutlineDeck"] = mixed_up
339
+ takeaways = ["Charge more.", "Ada sold a million copies.", "The paid tier grows."]
340
+ llm["WriteClosing"] = {"closing": {**CLOSING["closing"], "takeaways": takeaways}}
341
+
342
+ def check(**kw):
343
+ claims = kw["claims"]
344
+ return {"checks": [{"n": int(c.split(".")[0]), "supported": False, "fix": ""} for c in claims
345
+ if "award" in c or "million" in c]}
346
+
347
+ llm["CheckSlide"] = check
348
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
349
+ "document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
350
+ run_jobs()
351
+ slides = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]["deck"]["slides"]
352
+ assert "award" not in slides[0]["lead"] and slides[0]["heading"] == "Pricing for writers"
353
+ assert slides[-1]["takeaways"] == ["Charge more.", "The paid tier grows."]
354
+
355
+
356
+ def test_long_deck_has_parts_and_the_request_reaches_every_step(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
357
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
358
+ log: dict = {}
359
+ _fake_many(monkeypatch, pricing["path"], log)
360
+ layouts = ("section", "points", "stat", "points", "section", "quote", "points", "two_column", "section", "points",
361
+ "points", "points")
362
+ llm["OutlineDeck"] = _outline(12, layouts)
363
+ llm["CheckRequest"] = {"asks": [{"ask": "For new writers", "covered": True}]}
364
+ seen: dict = {}
365
+
366
+ def closing(**kw):
367
+ seen["closing"] = kw["request"]
368
+ return CLOSING
369
+
370
+ llm["WriteClosing"] = closing
371
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
372
+ "document_ids": [pricing["id"]], "deck_length": "long", "instructions": "For new writers"},
373
+ headers=auth).json()
374
+ run_jobs()
375
+ done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
376
+ assert done["status"] == "ready", done["content"].get("error")
377
+ slides = done["content"]["deck"]["slides"]
378
+ assert 12 <= len(slides) <= 16 and done["content"]["length"] == "long"
379
+ sections = [s["heading"] for s in slides if s["layout"] == "section"]
380
+ assert slides[1]["layout"] == "agenda" and [p["text"] for p in slides[1]["points"]] == sections # the parts
381
+ assert all(w["request"] == "For new writers" for w in log["WriteSlide"]) and seen["closing"] == "For new writers"
382
+ assert llm["_calls"].count("OutlineDeck") == 1 # nothing missed: no second plan
383
+
384
+
385
+ def test_a_missed_ask_is_planned_again(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
386
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
387
+ _fake_many(monkeypatch, pricing["path"], {})
388
+ requests: list[str] = []
389
+ first, second = _outline(4), _outline(5)
390
+
391
+ def outline(**kw):
392
+ requests.append(kw["request"])
393
+ out = (first if len(requests) == 1 else second)(**kw)
394
+ if len(requests) > 1:
395
+ out["outline"]["slides"][-1]["heading"] = "Pricing for beginners"
396
+ return out
397
+
398
+ llm["OutlineDeck"] = outline
399
+ llm["CheckRequest"] = {"asks": [{"ask": "a slide for beginners", "covered": False}, {"ask": "pricing", "covered": True}]}
400
+ llm["WriteClosing"] = CLOSING
401
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
402
+ "document_ids": [pricing["id"]], "deck_length": "short",
403
+ "instructions": "Pricing, with a slide for beginners"}, headers=auth).json()
404
+ run_jobs()
405
+ slides = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]["deck"]["slides"]
406
+ assert len(requests) == 2 and "a slide for beginners" in requests[1] and "pricing" not in requests[1].split("supports: ")[1]
407
+ assert any(s["heading"] == "Pricing for beginners" for s in slides)
408
+ assert client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
409
+ "document_ids": [pricing["id"]], "deck_length": "huge"}, headers=auth).status_code == 422
410
+
411
+
412
+ def test_the_closing_is_tried_again_and_a_repeated_heading_goes(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
413
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
414
+ _fake_many(monkeypatch, pricing["path"], {})
415
+ outline = _outline(6, ("points", "section", "points", "quote", "points", "two_column"))
416
+
417
+ def with_repeat(**kw):
418
+ out = outline(**kw)
419
+ out["outline"]["slides"][1]["heading"] = out["outline"]["slides"][2]["heading"] # a part titled like its slide
420
+ return out
421
+
422
+ llm["OutlineDeck"] = with_repeat
423
+ tries = []
424
+
425
+ def closing(**kw):
426
+ tries.append(1)
427
+ if len(tries) == 1:
428
+ raise RuntimeError("model hiccup")
429
+ return CLOSING
430
+
431
+ llm["WriteClosing"] = closing
432
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
433
+ "document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
434
+ run_jobs()
435
+ slides = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]["deck"]["slides"]
436
+ assert len(tries) == 2 and slides[-1]["closing"] == "Price the work like it matters."
437
+ headings = [s["heading"] for s in slides]
438
+ assert len(headings) == len(set(headings)) and 6 <= len(slides) <= 8
439
+
440
+
441
+ @pytest.mark.parametrize("key", [("title", "a"), ("title", "c"), ("section", "a"), ("section", "b")])
442
+ def test_light_planets_sit_on_their_ring(key):
443
+ """A planet beside rings is drawn on one of them, tilt included, whatever the seed."""
444
+ import math
445
+
446
+ from app.slides.decor import build
447
+
448
+ for seed in range(40):
449
+ shapes = build("light-space", *key, seed, 0).shapes
450
+ rings = [s for s in shapes if s.kind == "ring"]
451
+ for p in (s for s in shapes if s.kind == "planet"):
452
+ def off(r, p=p): # how far the planet's centre is from the ring's line, in units of the ring
453
+ rot = math.radians(-r.rot)
454
+ dx, dy = p.cx - r.cx, p.cy - r.cy
455
+ x, y = dx * math.cos(rot) - dy * math.sin(rot), dx * math.sin(rot) + dy * math.cos(rot)
456
+ return abs(math.hypot(x / r.rx, y / r.ry) - 1)
457
+ assert min(off(r) for r in rings) < 1e-6, (key, seed)
458
+
459
+
460
+ @pytest.mark.parametrize("key", [("title", "b"), ("points", "a")])
461
+ def test_dark_rings_wrap_round_their_planet(key):
462
+ """A ringed planet's far half is drawn before the planet (hidden behind it) and the near half after (in front)."""
463
+ from app.slides.decor import FAR, NEAR, build
464
+
465
+ for seed in range(10):
466
+ shapes = build("dark-space", *key, seed, 0).shapes
467
+ at = next(i for i, s in enumerate(shapes) if s.kind == "planet")
468
+ rings = [(i, s) for i, s in enumerate(shapes) if s.kind in ("ring", "band")]
469
+ assert rings and all(s.arc for _, s in rings)
470
+ assert all(i < at for i, s in rings if s.arc == FAR) and all(i > at for i, s in rings if s.arc == NEAR)
471
+ assert all(s.rx * s.inner > shapes[at].r for _, s in rings if s.kind == "band") # the band clears the planet
472
+
473
+
474
+ @pytest.mark.parametrize("theme", ["dark-space", "light-space"])
475
+ def test_every_variant_marks_its_editable_text(theme):
476
+ """Every piece of a slide's own text is drawn in a span the editor can find, with the field's real limit; numbers
477
+ and footers are not editable."""
478
+ from app.slides.content import field_limit
479
+
480
+ for fmt in ("detailed", "presenter"):
481
+ for layout, variants in GEOMETRY[theme].items():
482
+ for v in variants:
483
+ deck = clamp_deck({"title": "T", "slides": [{"layout": layout, **FULL[layout]}]}, fmt)
484
+ deck["slides"][0]["variant"] = v
485
+ html = slide_html(deck, 0, theme, fmt, 3)
486
+ found = dict(re.findall(r'data-f="([^"]+)" data-max="(\d+)"', html))
487
+ assert "heading" in found or layout in ("quote", "closing"), (layout, v)
488
+ assert all(int(n) == field_limit(layout, f, fmt) for f, n in found.items())
489
+ s = deck["slides"][0]
490
+ want = {"points": [f"points.{i}.text" for i in range(len(s["points"]))], "stat": ["stat.value", "stat.label"],
491
+ "quote": ["quote.text", "quote.by"], "two_column": ["left.label", "right.items.1"],
492
+ "closing": [f"takeaways.{i}" for i in range(len(s["takeaways"]))] + ["closing"]}.get(layout, [])
493
+ assert set(want) <= set(found), (fmt, layout, v, found)
494
+ assert not any(f.startswith("footer") for f in found)
495
+
496
+
497
+ def _ready_deck(client, auth, run_jobs, monkeypatch, llm): # noqa: F811
498
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
499
+ _fake_many(monkeypatch, pricing["path"], {})
500
+ llm["OutlineDeck"] = _outline(5)
501
+ llm["WriteClosing"] = CLOSING
502
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
503
+ "document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
504
+ run_jobs()
505
+ return client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
506
+
507
+
508
+ def test_edited_deck_is_saved_fitted_and_downloaded(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
509
+ done = _ready_deck(client, auth, run_jobs, monkeypatch, llm)
510
+ slides = copy.deepcopy(done["content"]["deck"]["slides"])
511
+ assert len(done["content"]["slide_slots"]) == len(slides) and "heading" in done["content"]["slide_slots"][0]
512
+ pi = next(i for i, s in enumerate(slides) if s["points"] and s["layout"] == "points")
513
+ slides[0]["heading"] = "A brand new title"
514
+ slides[0]["notes"] = "Say hello first."
515
+ slides[pi]["points"][0]["text"] = "Edited point " + "very long " * 60
516
+ slides[-1]["takeaways"][1] = " An <b>edited</b> takeaway "
517
+ url = f"/api/artifacts/{done['id']}/deck"
518
+ saved = client.put(url, json={"slides": slides}, headers=auth)
519
+ assert saved.status_code == 200, saved.text
520
+ after = saved.json()["content"]
521
+ s = after["deck"]["slides"]
522
+ assert s[0]["heading"] == "A brand new title" and s[0]["notes"] == "Say hello first."
523
+ assert s[pi]["points"][0]["text"].startswith("Edited point") and len(s[pi]["points"][0]["text"]) <= LIMITS["detailed"]["text"]
524
+ assert s[-1]["takeaways"][1] == "An edited takeaway" # plain text only
525
+ before = done["content"]["deck"]["slides"]
526
+ assert [(x["layout"], x["variant"]) for x in s] == [(x["layout"], x["variant"]) for x in before] # designs kept
527
+ assert "A brand new title" in after["slides_html"][0]
528
+ pptx = client.get(f"/api/artifacts/{done['id']}/slides.pptx", headers=auth)
529
+ texts = [sh.text_frame.text for sh in Presentation(io.BytesIO(pptx.content)).slides[0].shapes if sh.has_text_frame]
530
+ assert any("A brand new title" in t for t in texts)
531
+
532
+ assert client.put(url, json={"slides": []}, headers=auth).status_code == 422
533
+ assert client.put(url, json={"slides": [{"layout": "points", "heading": ""}]}, headers=auth).status_code == 400
534
+ assert client.put(url, json={"slides": [slides[0]] * 31}, headers=auth).status_code == 400
535
+ kept = client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"]["deck"]["slides"]
536
+ assert kept[0]["heading"] == "A brand new title" and len(kept) == len(slides) # a refused save changes nothing
537
+
538
+
539
+ def test_slides_can_be_added_removed_moved_and_change_layout(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
540
+ done = _ready_deck(client, auth, run_jobs, monkeypatch, llm)
541
+ slides = copy.deepcopy(done["content"]["deck"]["slides"])
542
+ n = len(slides)
543
+ pi = next(i for i, s in enumerate(slides) if s["layout"] == "points")
544
+ edited = copy.deepcopy(slides)
545
+ edited[pi]["points"].append({"term": "", "text": "A point added by hand"}) # a text box added
546
+ edited[pi]["lead"] = "" # and one removed
547
+ stat = {"layout": "stat", "variant": "", "heading": "A new stat slide", "stat": {"value": "42%", "label": "Readers who stayed"}}
548
+ edited.insert(1, stat) # a slide added
549
+ edited.insert(2, copy.deepcopy(edited[0]) | {"variant": ""}) # a slide duplicated
550
+ del edited[-2] # a slide removed
551
+ edited[-1], edited[-2] = edited[-2], edited[-1] # two moved
552
+ url = f"/api/artifacts/{done['id']}"
553
+ prev = client.post(f"{url}/deck-preview", json={"slides": edited}, headers=auth)
554
+ assert prev.status_code == 200, prev.text
555
+ got = prev.json()
556
+ out = got["slides"]
557
+ assert len(out) == n + 1 and len(got["slides_html"]) == n + 1 and len(got["slide_slots"]) == n + 1
558
+ assert out[1]["layout"] == "stat" and out[1]["variant"] and "42%" in got["slides_html"][1]
559
+ assert "value" in got["slide_slots"][1] and "label" in got["slide_slots"][1]
560
+ moved = next(s for s in out if s["heading"] == slides[pi]["heading"])
561
+ assert moved["points"][-1]["text"] == "A point added by hand" and moved["lead"] == ""
562
+ pairs = [(x["layout"], x["variant"]) for x in out]
563
+ assert all(a != b for a, b in zip(pairs, pairs[1:], strict=False)) # no two alike in a row
564
+ assert client.get(url, headers=auth).json()["content"]["deck"]["slides"] == done["content"]["deck"]["slides"] # preview saves nothing
565
+
566
+ # a layout change: the points slide becomes a quote slide
567
+ q = next(i for i, s in enumerate(edited) if s["layout"] == "points")
568
+ edited[q] = {**edited[q], "layout": "quote", "variant": "", "quote": {"text": "Price the work like it matters.", "by": "A writer"}}
569
+ saved = client.put(f"{url}/deck", json={"slides": edited}, headers=auth).json()["content"]
570
+ assert saved["deck"]["slides"][q]["layout"] == "quote" and saved["deck"]["slides"][q]["variant"]
571
+ assert len(saved["deck"]["slides"]) == n + 1
572
+ pptx = client.get(f"{url}/slides.pptx", headers=auth)
573
+ assert len(Presentation(io.BytesIO(pptx.content)).slides) == n + 1
574
+
575
+
576
+ def test_fill_variants_keeps_good_variants_and_fixes_the_rest():
577
+ from app.slides.build import fill_variants
578
+
579
+ deck = clamp_deck({"title": "T", "slides": [{"layout": lay, **FULL[lay]} for lay in ("title", "points", "points", "stat", "closing")]},
580
+ "detailed")
581
+ assign_variants(deck["slides"], "dark-space", seed=3)
582
+ before = [s["variant"] for s in deck["slides"]]
583
+ fill_variants(deck["slides"], "dark-space", 3)
584
+ assert [s["variant"] for s in deck["slides"]] == before # nothing to fix: nothing changes
585
+ deck["slides"][3]["variant"] = "" # new
586
+ deck["slides"][2]["variant"] = deck["slides"][1]["variant"] # the same as the slide before it
587
+ fill_variants(deck["slides"], "dark-space", 3)
588
+ assert deck["slides"][3]["variant"] in VARIANTS["stat"]
589
+ assert deck["slides"][2]["variant"] != deck["slides"][1]["variant"]
590
+ assert [s["variant"] for s in deck["slides"]][:2] == before[:2] and deck["slides"][4]["variant"] == before[4]
backend/tests/test_social_posting.py CHANGED
@@ -47,8 +47,8 @@ def account(db, ws, platform="x", scopes=None, expires_in=None, refresh=None, ex
47
  return acct
48
 
49
 
50
- def item(db, ws, acct, minutes=60, status="scheduled", **kw):
51
- it = CalendarItem(workspace_id=ws.id, platform=acct.platform, content="Hello", social_account_id=acct.id,
52
  scheduled_at=datetime.now(UTC) + timedelta(minutes=minutes), status=status, **kw)
53
  db.add(it)
54
  db.commit()
@@ -552,3 +552,171 @@ def test_uploaded_files_carry_a_preview_link(client, auth, ws, db_session):
552
  kit = made(db_session, ws, "launch_kit", post_title="Kit", posts={"linkedin": ["Hi"]})
553
  listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
554
  assert listed[str(kit.id)]["view_url"] is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  return acct
48
 
49
 
50
+ def item(db, ws, acct, minutes=60, status="scheduled", content="Hello", **kw):
51
+ it = CalendarItem(workspace_id=ws.id, platform=acct.platform, content=content, social_account_id=acct.id,
52
  scheduled_at=datetime.now(UTC) + timedelta(minutes=minutes), status=status, **kw)
53
  db.add(it)
54
  db.commit()
 
552
  kit = made(db_session, ws, "launch_kit", post_title="Kit", posts={"linkedin": ["Hi"]})
553
  listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
554
  assert listed[str(kit.id)]["view_url"] is None
555
+
556
+
557
+ def test_a_slide_deck_is_listed_and_posts_its_slides_as_images(client, auth, ws, db_session, monkeypatch):
558
+ from app.services.social import media
559
+ from app.slides import export
560
+ from app.slides.build import fit
561
+ from app.slides.content import clamp_deck
562
+
563
+ raw = {"title": "Pricing for writers", "subtitle": "What two years taught us.", "slides": [
564
+ {"layout": "title", "heading": "Pricing for writers", "lead": "What two years taught us."},
565
+ *[{"layout": "section", "heading": f"Part {i}"} for i in range(5)],
566
+ {"layout": "closing", "heading": "Key takeaways", "takeaways": ["Charge more.", "Readers take it seriously."]}]}
567
+ deck = fit(clamp_deck(raw, "detailed"), "dark-space", "detailed", 3)
568
+ art = Artifact(workspace_id=ws.id, type="slide_deck", status="ready",
569
+ content_json={"title": "Slide deck: Pricing for writers", "theme": "dark-space", "format": "detailed",
570
+ "seed": "abc", "deck": deck})
571
+ db_session.add(art)
572
+ db_session.commit()
573
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}[str(art.id)]
574
+ assert listed["type"] == "slide_deck" and listed["media"] == "image" and listed["media_count"] == 7
575
+ assert listed["title"] == "Pricing for writers"
576
+ assert listed["prefill"]["linkedin"].startswith("Pricing for writers\n\nWhat two years taught us.")
577
+ assert "- Charge more." in listed["prefill"]["linkedin"]
578
+
579
+ drawn: list[int] = []
580
+
581
+ def chrome(html, args, out): # a screenshot of the size asked for: a batch of slides stacked
582
+ import io
583
+
584
+ from PIL import Image
585
+
586
+ w, h = (int(v) for v in next(a for a in args if a.startswith("--window-size=")).split("=")[1].split(","))
587
+ drawn.append(h // 1080)
588
+ buf = io.BytesIO()
589
+ Image.new("RGB", (w, h), "navy").save(buf, "PNG")
590
+ return buf.getvalue(), ""
591
+
592
+ monkeypatch.setattr(export, "_run_chrome", chrome)
593
+ with media.media_for(art, "x") as files: # X takes 4 images: one Chrome run draws them
594
+ assert [f.filename for f in files] == [f"slide-{i}.png" for i in range(1, 5)] and drawn == [4]
595
+ assert all(f.data.startswith(b"\x89PNG") for f in files)
596
+ with media.media_for(art, "linkedin") as files:
597
+ assert len(files) == 7 and all(f.content_type == "image/png" for f in files)
598
+ acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
599
+ assert schedule(client, auth, "linkedin", acct, artifact_id=str(art.id)).status_code == 200
600
+ r = schedule(client, auth, "bluesky", artifact_id=str(art.id))
601
+ assert r.status_code == 400 and "X and LinkedIn" in r.json()["detail"] # images go to X and LinkedIn only
602
+
603
+
604
+ def test_a_slide_deck_that_cannot_be_drawn_is_retried(ws, db_session, monkeypatch):
605
+ from app.infographics.image import ImageUnavailable
606
+ from app.services.social import SocialError, media
607
+ from app.slides import export
608
+
609
+ art = Artifact(workspace_id=ws.id, type="slide_deck", status="ready", content_json={
610
+ "format": "detailed", "deck": {"title": "T", "slides": [{"layout": "section", "heading": "Hello"}]}})
611
+ db_session.add(art)
612
+ db_session.commit()
613
+
614
+ def no_chrome(*a, **k):
615
+ raise ImageUnavailable("No Chrome")
616
+
617
+ monkeypatch.setattr(export, "_run_chrome", no_chrome)
618
+ with pytest.raises(SocialError, match="could not be drawn") as err, media.media_for(art, "x"):
619
+ pass
620
+ assert not err.value.permanent
621
+
622
+
623
+ def test_an_audio_overview_is_listed_to_download_but_never_posted(client, auth, ws, db_session):
624
+ audio = made(db_session, ws, "audio_overview", key="ws/a/overview.mp3", title="Audio overview: Pricing", duration_s=225)
625
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}[str(audio.id)]
626
+ assert listed["media"] == "audio" and listed["media_count"] == 0 and listed["duration_s"] == 225
627
+ assert listed["download_url"]
628
+ acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
629
+ r = schedule(client, auth, "linkedin", acct, artifact_id=str(audio.id))
630
+ assert r.status_code == 400 and "Audio can't be posted" in r.json()["detail"]
631
+
632
+
633
+ def test_a_video_or_image_can_go_out_without_a_caption(client, auth, ws, db_session, monkeypatch):
634
+ """An upload starts with no words (they are the writer's to add): a post that carries media may stay that way.
635
+ Without media, a post still needs words."""
636
+ from app.services import launchpad
637
+ from app.services.social import linkedin, x
638
+
639
+ li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
640
+ upload = made(db_session, ws, "upload", key="ws/u/clip.mp4", data=os.urandom(100_000), media="video",
641
+ content_type="video/mp4", filename="clip.mp4")
642
+ r = schedule(client, auth, "linkedin", li, content="", artifact_id=str(upload.id))
643
+ assert r.status_code == 200, r.text
644
+ r = schedule(client, auth, "linkedin", li, content=" ")
645
+ assert r.status_code == 422 and r.json()["detail"]["message"] == "Write something to post, or attach an image or video."
646
+ created = schedule(client, auth, "linkedin", li, content="Words", artifact_id=str(upload.id)).json()
647
+ r = client.patch(f"/api/calendar/{created['id']}", json={"content": "", "artifact_id": None}, headers=auth)
648
+ assert r.status_code == 422 and r.json()["detail"]["code"] == "no_text" # emptied and no media left
649
+
650
+ # Publishing: LinkedIn gets an empty caption, X a post with the media and no "text" at all.
651
+ sent: dict = {}
652
+ monkeypatch.setattr(linkedin, "_upload_video", lambda acct, m: "urn:li:video:1")
653
+ monkeypatch.setattr(linkedin, "_call", lambda method, url, **kw: sent.setdefault("li", kw["json"]) and httpx.Response(
654
+ 201, headers={"x-restli-id": "urn:li:share:1"}, request=httpx.Request(method, url)))
655
+ li_item = item(db_session, ws, li, minutes=0, content="", artifact_id=upload.id)
656
+ launchpad.publish_item(db_session, li_item)
657
+ assert li_item.status == "posted" and sent["li"]["commentary"] == "" and sent["li"]["content"]["media"]["id"]
658
+
659
+ xa = account(db_session, ws, "x", scopes="tweet.write media.write")
660
+ monkeypatch.setattr(x, "can_post_media", lambda a: True)
661
+ monkeypatch.setattr(x, "_upload_video", lambda headers, m: "m1")
662
+ monkeypatch.setattr(x, "_access", lambda db, a: "token")
663
+ monkeypatch.setattr(x, "_call", lambda method, url, **kw: sent.setdefault("x", kw["json"]) and httpx.Response(
664
+ 201, json={"data": {"id": "t1"}}, request=httpx.Request(method, url)))
665
+ x_item = item(db_session, ws, xa, minutes=0, content="", artifact_id=upload.id)
666
+ launchpad.publish_item(db_session, x_item)
667
+ assert x_item.status == "posted" and sent["x"] == {"media": {"media_ids": ["m1"]}}
668
+
669
+ text_only = item(db_session, ws, xa, minutes=0, content="")
670
+ launchpad.publish_item(db_session, text_only)
671
+ assert text_only.status == "failed" and text_only.error == "Nothing to post."
672
+
673
+
674
+ def test_post_now_publishes_in_the_same_request_and_is_never_left_scheduled(client, auth, ws, db_session, monkeypatch):
675
+ from app.models import Job
676
+ from app.services.social import linkedin
677
+
678
+ li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
679
+ monkeypatch.setattr(linkedin, "publish", lambda db, a, posts, media=None: ("urn:li:share:9", "https://linkedin.com/x"))
680
+ now = lambda **kw: client.post("/api/calendar", json={ # noqa: E731 (no time sent: the server takes it)
681
+ "platform": "linkedin", "content": "Hello", "social_account_id": str(li.id), "publish_now": True, **kw}, headers=auth)
682
+
683
+ text = now()
684
+ assert text.status_code == 200, text.text
685
+ assert text.json()["status"] == "posted" and text.json()["external_url"] == "https://linkedin.com/x"
686
+
687
+ video = made(db_session, ws, "video", key="ws/v/now.mp4", data=os.urandom(100_000), duration_s=30)
688
+ media = now(artifact_id=str(video.id)).json()
689
+ assert media["status"] == "publishing" # uploading goes through a job: never "scheduled" in between
690
+ job = db_session.query(Job).filter(Job.kind == "publish_post").one()
691
+ assert job.params["item_id"] == media["id"]
692
+
693
+
694
+ def test_saving_a_failed_or_draft_post_reschedules_it_at_a_time_still_ahead(client, auth, ws, db_session):
695
+ li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
696
+ year = datetime.now(UTC).year + 1
697
+ for status in ("failed", "draft", "reminded"):
698
+ it = item(db_session, ws, li, minutes=-90, status=status)
699
+ # Its old time has passed: rescheduled there, it would go out at the very next run.
700
+ r = client.patch(f"/api/calendar/{it.id}", json={"status": "scheduled"}, headers=auth)
701
+ assert r.status_code == 409 and r.json()["detail"]["code"] == "missed", status
702
+ r = client.patch(f"/api/calendar/{it.id}", json={"status": "scheduled", "local_time": f"{year}-01-05T10:30",
703
+ "timezone": "UTC"}, headers=auth)
704
+ assert r.status_code == 200, r.text
705
+ got = r.json()
706
+ assert got["status"] == "scheduled" and got["error"] is None
707
+ assert datetime.fromisoformat(got["scheduled_at"]) == datetime(year, 1, 5, 10, 30, tzinfo=UTC)
708
+
709
+
710
+ def test_the_list_says_what_each_item_already_has_scheduled(client, auth, ws, db_session):
711
+ li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
712
+ deck = made(db_session, ws, "summary", summary="Raise prices once a year.")
713
+ video = made(db_session, ws, "video", key="ws/v/s.mp4", data=os.urandom(100_000), duration_s=30)
714
+ later = item(db_session, ws, li, minutes=600, artifact_id=deck.id)
715
+ sooner = item(db_session, ws, li, minutes=120, artifact_id=deck.id)
716
+ item(db_session, ws, li, minutes=-60, artifact_id=deck.id) # its time has passed
717
+ item(db_session, ws, li, minutes=300, status="posted", artifact_id=deck.id)
718
+ item(db_session, ws, li, minutes=300, status="failed", artifact_id=deck.id)
719
+ listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
720
+ assert [s["id"] for s in listed[str(deck.id)]["scheduled"]] == [str(sooner.id), str(later.id)]
721
+ assert listed[str(deck.id)]["scheduled"][0]["platform_label"] == "LinkedIn"
722
+ assert listed[str(video.id)]["scheduled"] == []
frontend/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
2
  import AppShell from "./components/AppShell";
3
  import { PublicFooter, PublicNav } from "./components/PublicChrome";
4
  import PricingTiers from "./components/PricingTiers";
@@ -8,6 +8,7 @@ import AuthPage from "./pages/AuthPage";
8
  import { Blog, BlogPostPage } from "./pages/Blog";
9
  import Landing from "./pages/Landing";
10
  import Archive from "./pages/Archive";
 
11
  import Launchpad, { ScheduleLaunch } from "./pages/Launchpad";
12
  import LaunchKitRedirect, { LaunchKitPage, LaunchKits } from "./pages/LaunchKit";
13
  import MissionControl from "./pages/MissionControl";
@@ -64,7 +65,7 @@ export default function App() {
64
  <Route path="reports/:id" element={<ReportPage />} />
65
  <Route path="videos" element={<Navigate to="/app/archive?tab=videos" replace />} />
66
  <Route path="videos/new" element={<VideoCreate />} />
67
- <Route path="videos/voices" element={<Navigate to="/app/archive?tab=voices" replace />} />
68
  <Route path="videos/:id" element={<VideoEditor />} />
69
  <Route path="sources" element={<Sources />} />
70
  <Route path="map" element={<TopicMap />} />
@@ -76,6 +77,7 @@ export default function App() {
76
  <Route path="launchpad/kits" element={<LaunchKits />} />
77
  <Route path="launchpad/kits/:id" element={<LaunchKitPage />} />
78
  <Route path="archive" element={<Archive />} />
 
79
  <Route path="resurface" element={<Resurface />} />
80
  <Route path="settings" element={<Settings />} />
81
  <Route path="*" element={<MissionControl />} />
@@ -87,9 +89,7 @@ export default function App() {
87
  );
88
  }
89
 
90
- /** The old Voice page (/app/voice, ?step=writing for the writing voice) is the Library's Voices tab now. */
91
  function VoiceRedirect() {
92
- const [params] = useSearchParams();
93
- const writing = params.get("step") === "writing";
94
- return <Navigate to={`/app/archive?tab=voices${writing ? "&step=writing" : ""}`} replace />;
95
  }
 
1
+ import { Navigate, Route, Routes } from "react-router-dom";
2
  import AppShell from "./components/AppShell";
3
  import { PublicFooter, PublicNav } from "./components/PublicChrome";
4
  import PricingTiers from "./components/PricingTiers";
 
8
  import { Blog, BlogPostPage } from "./pages/Blog";
9
  import Landing from "./pages/Landing";
10
  import Archive from "./pages/Archive";
11
+ import Voices from "./pages/Voices";
12
  import Launchpad, { ScheduleLaunch } from "./pages/Launchpad";
13
  import LaunchKitRedirect, { LaunchKitPage, LaunchKits } from "./pages/LaunchKit";
14
  import MissionControl from "./pages/MissionControl";
 
65
  <Route path="reports/:id" element={<ReportPage />} />
66
  <Route path="videos" element={<Navigate to="/app/archive?tab=videos" replace />} />
67
  <Route path="videos/new" element={<VideoCreate />} />
68
+ <Route path="videos/voices" element={<Navigate to="/app/voices" replace />} />
69
  <Route path="videos/:id" element={<VideoEditor />} />
70
  <Route path="sources" element={<Sources />} />
71
  <Route path="map" element={<TopicMap />} />
 
77
  <Route path="launchpad/kits" element={<LaunchKits />} />
78
  <Route path="launchpad/kits/:id" element={<LaunchKitPage />} />
79
  <Route path="archive" element={<Archive />} />
80
+ <Route path="voices" element={<Voices />} />
81
  <Route path="resurface" element={<Resurface />} />
82
  <Route path="settings" element={<Settings />} />
83
  <Route path="*" element={<MissionControl />} />
 
89
  );
90
  }
91
 
92
+ /** The old Voice page (/app/voice) is Manage voices (/app/voices) now. */
93
  function VoiceRedirect() {
94
+ return <Navigate to="/app/voices" replace />;
 
 
95
  }
frontend/src/api/client.ts CHANGED
@@ -63,7 +63,12 @@ async function parseError(res: Response): Promise<ApiError> {
63
  const body = await res.json();
64
  const detail = body.detail;
65
  if (typeof detail === "string") message = detail;
66
- else if (detail?.message) {
 
 
 
 
 
67
  message = detail.message;
68
  code = detail.code;
69
  extra = detail;
 
63
  const body = await res.json();
64
  const detail = body.detail;
65
  if (typeof detail === "string") message = detail;
66
+ else if (Array.isArray(detail) && detail.length) {
67
+ // A request the server's field checks refused (FastAPI's 422 list): say which field and why, not "Unprocessable Entity".
68
+ const first = detail[0] as { loc?: (string | number)[]; msg?: string };
69
+ const field = (first.loc ?? []).filter((p) => p !== "body").join(" ");
70
+ if (first.msg) message = field ? `${field}: ${first.msg}` : first.msg;
71
+ } else if (detail?.message) {
72
  message = detail.message;
73
  code = detail.code;
74
  extra = detail;
frontend/src/api/endpoints.ts CHANGED
@@ -116,7 +116,9 @@ export type GenerateBody = {
116
  document_id?: string;
117
  /** No notebook or post: use the "All posts" notebook. */
118
  archive?: boolean;
119
- format?: "deep_dive" | "brief" | "debate";
 
 
120
  minutes?: number;
121
  style?: "audiogram";
122
  audio_artifact_id?: string;
@@ -139,8 +141,11 @@ export type GenerateBody = {
139
  report_format?: "document" | "interactive";
140
  template_id?: string;
141
  instructions?: string;
142
- /** infographic: one of the premade themes (see components/Infographic.tsx). */
143
  theme?: string;
 
 
 
144
  };
145
 
146
  export const artifactsApi = {
@@ -154,6 +159,33 @@ export const artifactsApi = {
154
  /** An infographic drawn as a PNG. */
155
  image: (id: string, layout: "landscape" | "portrait" = "landscape") => apiBlob(`/api/artifacts/${id}/image${qs({ layout })}`),
156
  retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  };
158
 
159
  export const jobsApi = {
@@ -217,6 +249,8 @@ export type ItemBody = {
217
  social_account_id?: string | null;
218
  remind_by_email?: boolean;
219
  draft?: boolean;
 
 
220
  };
221
 
222
  export const launchpadApi = {
 
116
  document_id?: string;
117
  /** No notebook or post: use the "All posts" notebook. */
118
  archive?: boolean;
119
+ format?: "deep_dive" | "brief" | "critique" | "debate";
120
+ /** audio_overview: a single narrator or a two host conversation. */
121
+ hosts?: 1 | 2;
122
  minutes?: number;
123
  style?: "audiogram";
124
  audio_artifact_id?: string;
 
141
  report_format?: "document" | "interactive";
142
  template_id?: string;
143
  instructions?: string;
144
+ /** infographic: one of the premade themes (see components/Infographic.tsx); slide_deck: dark-space or light-space. */
145
  theme?: string;
146
+ /** slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides. */
147
+ deck_format?: "detailed" | "presenter";
148
+ deck_length?: "short" | "default" | "long";
149
  };
150
 
151
  export const artifactsApi = {
 
159
  /** An infographic drawn as a PNG. */
160
  image: (id: string, layout: "landscape" | "portrait" = "landscape") => apiBlob(`/api/artifacts/${id}/image${qs({ layout })}`),
161
  retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
162
+ /** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
163
+ slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
164
+ /** A slide deck's slides as the editor has them, drawn without saving (after a slide or a text box is added,
165
+ * removed, moved or changed to another layout). Returns them as they will be kept. */
166
+ previewDeck: (id: string, slides: EditSlide[]) =>
167
+ post<{ slides: EditSlide[]; slides_html: string[]; slide_slots: string[][] }>(`/api/artifacts/${id}/deck-preview`, { slides }),
168
+ /** The editor's slides kept: the server fits the deck again and returns it. */
169
+ saveDeck: (id: string, slides: EditSlide[]) => put<Artifact>(`/api/artifacts/${id}/deck`, { slides }),
170
+ };
171
+
172
+ /** One slide in the stored shape (backend app/slides/content.py, empty_slide). */
173
+ export type EditSlide = {
174
+ layout: string;
175
+ variant: string;
176
+ kicker: string;
177
+ heading: string;
178
+ lead: string;
179
+ points: { term: string; text: string }[];
180
+ left: { label: string; items: string[] };
181
+ right: { label: string; items: string[] };
182
+ stat: { value: string; label: string };
183
+ quote: { text: string; by: string };
184
+ takeaways: string[];
185
+ closing: string;
186
+ notes: string;
187
+ sources: { title?: string; path: string; line_start: number; line_end: number }[];
188
+ shrink?: number;
189
  };
190
 
191
  export const jobsApi = {
 
249
  social_account_id?: string | null;
250
  remind_by_email?: boolean;
251
  draft?: boolean;
252
+ /** Post now: published (or reminded) in the same request, never left scheduled. */
253
+ publish_now?: boolean;
254
  };
255
 
256
  export const launchpadApi = {
frontend/src/api/types.ts CHANGED
@@ -89,7 +89,7 @@ export type AnswerFeedback = { rating: "up" | "down"; reasons: string[]; comment
89
  export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
90
  export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[]; feedback?: AnswerFeedback | null };
91
 
92
- export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit" | "mind_map" | "quiz" | "flashcards" | "report" | "infographic"| "upload";
93
 
94
  export type QuizQuestionType = "multiple_choice" | "multiple_select" | "fill_blank" | "short_answer";
95
  export type QuizQuestion = {
@@ -320,7 +320,8 @@ export type PostableArtifact = {
320
  type: ArtifactType;
321
  type_label: string;
322
  title: string;
323
- media: "image" | "video" | "text";
 
324
  media_count: number;
325
  duration_s: number | null;
326
  thumb_url: string | null;
@@ -332,6 +333,10 @@ export type PostableArtifact = {
332
  editable_video: boolean;
333
  /** An uploaded file's link, to preview it (null for anything else). */
334
  view_url: string | null;
 
 
 
 
335
  created_at: string | null;
336
  /** A starting text: X as a thread (list of posts), LinkedIn as one post. */
337
  prefill: { x: string[]; linkedin: string };
 
89
  export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
90
  export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[]; feedback?: AnswerFeedback | null };
91
 
92
+ export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit" | "mind_map" | "quiz" | "flashcards" | "report" | "infographic" | "slide_deck" | "upload";
93
 
94
  export type QuizQuestionType = "multiple_choice" | "multiple_select" | "fill_blank" | "short_answer";
95
  export type QuizQuestion = {
 
320
  type: ArtifactType;
321
  type_label: string;
322
  title: string;
323
+ /** "audio": an audio overview, listed to download only (X and LinkedIn take no audio files). */
324
+ media: "image" | "video" | "audio" | "text";
325
  media_count: number;
326
  duration_s: number | null;
327
  thumb_url: string | null;
 
333
  editable_video: boolean;
334
  /** An uploaded file's link, to preview it (null for anything else). */
335
  view_url: string | null;
336
+ /** An audio overview's file, saved as a download (null for anything else). */
337
+ download_url?: string | null;
338
+ /** Its posts still to go out (scheduled, ahead), soonest first: the Schedule a launch list offers to reschedule them. */
339
+ scheduled?: { id: string; scheduled_at: string; platform_label: string }[];
340
  created_at: string | null;
341
  /** A starting text: X as a thread (list of posts), LinkedIn as one post. */
342
  prefill: { x: string[]; linkedin: string };
frontend/src/components/AppShell.tsx CHANGED
@@ -9,6 +9,7 @@ import UpgradeModal from "./UpgradeModal";
9
  import {
10
  LaunchpadIcon,
11
  LibraryIcon,
 
12
  OrbitIcon,
13
  PlanetIcon,
14
  SatelliteDishIcon,
@@ -20,6 +21,7 @@ const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean;
20
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
21
  { to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
22
  { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface", "/app/launch-kit"] },
 
23
  ];
24
 
25
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
 
9
  import {
10
  LaunchpadIcon,
11
  LibraryIcon,
12
+ MicIcon,
13
  OrbitIcon,
14
  PlanetIcon,
15
  SatelliteDishIcon,
 
21
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
22
  { to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
23
  { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface", "/app/launch-kit"] },
24
+ { to: "/app/voices", label: "Manage voices", icon: MicIcon },
25
  ];
26
 
27
  const COLLAPSED_KEY = "ns_sidebar_collapsed";
frontend/src/components/ArtifactCard.tsx CHANGED
@@ -68,7 +68,7 @@ export function CitationList({
68
  );
69
  }
70
 
71
- function Transcript({ segments, onSeek }: { segments: Segment[]; onSeek: (t: number) => void }) {
72
  return (
73
  <ol className="transcript">
74
  {segments.map((s, i) => (
 
68
  );
69
  }
70
 
71
+ export function Transcript({ segments, onSeek }: { segments: Segment[]; onSeek: (t: number) => void }) {
72
  return (
73
  <ol className="transcript">
74
  {segments.map((s, i) => (
frontend/src/components/ArtifactRow.tsx CHANGED
@@ -4,6 +4,7 @@ import { artifactsApi, videosApi } from "../api/endpoints";
4
  import type { Artifact, Citation } from "../api/types";
5
  import { useJob } from "../hooks/useJob";
6
  import { Body } from "./ArtifactCard";
 
7
  import { TrashIcon } from "./icons/Icons";
8
  import { errorMessage, formatDate, formatDuration, JobProgress } from "./ui";
9
 
@@ -14,7 +15,8 @@ function statusOf(a: Artifact, running: boolean): { label: string; tone: "done"
14
  }
15
 
16
  /** One generated item as a full-width row (like the Videos list): its type, title, status and date. A video opens its
17
- * editor; anything else expands in place to its player or image. */
 
18
  export function ArtifactRow({
19
  artifact: initial,
20
  onCite,
@@ -41,6 +43,8 @@ export function ArtifactRow({
41
  const running = !!job && (job.status === "queued" || job.status === "running");
42
  const st = statusOf(artifact, running);
43
  const isEditorVideo = artifact.provider === "blog2video";
 
 
44
 
45
  useEffect(() => {
46
  if (!armed) return;
@@ -73,7 +77,8 @@ export function ArtifactRow({
73
  <>
74
  <span className="vw-list-title">
75
  <span className={`type-chip ${artifact.type}`}>{artifact.type_label}</span>
76
- <strong>{artifact.title}</strong>
 
77
  <span className={`vw-dot ${st.tone}`} aria-hidden="true" />
78
  <span className="muted">{st.label}</span>
79
  </span>
@@ -90,12 +95,17 @@ export function ArtifactRow({
90
  <div className="artifact-row-head">
91
  {isEditorVideo ? (
92
  <Link to={`/app/videos/${artifact.id}`} className="vw-list-main">{main}</Link>
 
 
 
 
93
  ) : (
94
  <button type="button" className="vw-list-main artifact-row-toggle" aria-expanded={openRow}
95
  onClick={() => setOpenRow((o) => !o)}>
96
  {main}
97
  </button>
98
  )}
 
99
  {artifact.status === "failed" && (
100
  <button type="button" className="btn btn-small" onClick={retry}>Retry</button>
101
  )}
@@ -109,7 +119,17 @@ export function ArtifactRow({
109
  </div>
110
  {running && job && <JobProgress job={job} compact />}
111
  {error && <p className="error-text">{error}</p>}
112
- {openRow && !isEditorVideo && (
 
 
 
 
 
 
 
 
 
 
113
  <div className="artifact-row-body">
114
  {artifact.status === "failed" && artifact.content.error && <p className="error-text">{artifact.content.error}</p>}
115
  <Body artifact={artifact} onCite={onCite} />
 
4
  import type { Artifact, Citation } from "../api/types";
5
  import { useJob } from "../hooks/useJob";
6
  import { Body } from "./ArtifactCard";
7
+ import { SlideDeckViewer } from "./SlideDeck";
8
  import { TrashIcon } from "./icons/Icons";
9
  import { errorMessage, formatDate, formatDuration, JobProgress } from "./ui";
10
 
 
15
  }
16
 
17
  /** One generated item as a full-width row (like the Videos list): its type, title, status and date. A video opens its
18
+ * editor, a finished slide deck its viewer (to read, edit, present or download it); anything else expands in place
19
+ * to its player or image. */
20
  export function ArtifactRow({
21
  artifact: initial,
22
  onCite,
 
43
  const running = !!job && (job.status === "queued" || job.status === "running");
44
  const st = statusOf(artifact, running);
45
  const isEditorVideo = artifact.provider === "blog2video";
46
+ const isDeck = artifact.type === "slide_deck" && artifact.status === "ready";
47
+ const [viewing, setViewing] = useState(false);
48
 
49
  useEffect(() => {
50
  if (!armed) return;
 
77
  <>
78
  <span className="vw-list-title">
79
  <span className={`type-chip ${artifact.type}`}>{artifact.type_label}</span>
80
+ {/* A deck's title starts "Slide deck: ", which its chip already says */}
81
+ <strong>{artifact.type === "slide_deck" ? artifact.title.replace(/^Slide deck: /, "") : artifact.title}</strong>
82
  <span className={`vw-dot ${st.tone}`} aria-hidden="true" />
83
  <span className="muted">{st.label}</span>
84
  </span>
 
95
  <div className="artifact-row-head">
96
  {isEditorVideo ? (
97
  <Link to={`/app/videos/${artifact.id}`} className="vw-list-main">{main}</Link>
98
+ ) : isDeck ? (
99
+ <button type="button" className="vw-list-main artifact-row-toggle" onClick={() => setViewing(true)}>
100
+ {main}
101
+ </button>
102
  ) : (
103
  <button type="button" className="vw-list-main artifact-row-toggle" aria-expanded={openRow}
104
  onClick={() => setOpenRow((o) => !o)}>
105
  {main}
106
  </button>
107
  )}
108
+ {isDeck && actions?.(artifact)}
109
  {artifact.status === "failed" && (
110
  <button type="button" className="btn btn-small" onClick={retry}>Retry</button>
111
  )}
 
119
  </div>
120
  {running && job && <JobProgress job={job} compact />}
121
  {error && <p className="error-text">{error}</p>}
122
+ {viewing && (
123
+ <SlideDeckViewer artifact={artifact} onClose={() => setViewing(false)} onSaved={(a) => {
124
+ setArtifact(a);
125
+ onChanged?.(a);
126
+ }} onDelete={async () => {
127
+ await artifactsApi.remove(artifact.id);
128
+ setViewing(false);
129
+ onRemoved?.(artifact.id);
130
+ }} />
131
+ )}
132
+ {openRow && !isEditorVideo && !isDeck && (
133
  <div className="artifact-row-body">
134
  {artifact.status === "failed" && artifact.content.error && <p className="error-text">{artifact.content.error}</p>}
135
  <Body artifact={artifact} onCite={onCite} />
frontend/src/components/AudioOverview.tsx ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState, type RefObject } from "react";
2
+ import { artifactsApi, videoVoicesApi, type GenerateBody } from "../api/endpoints";
3
+ import type { Artifact, ChatSummary, Segment, VideoSavedVoice } from "../api/types";
4
+ import { useJob } from "../hooks/useJob";
5
+ import { Transcript } from "./ArtifactCard";
6
+ import { Dropdown } from "./Dropdown";
7
+ import { CheckIcon, ChevronIcon, HeadphonesIcon } from "./icons/Icons";
8
+ import { LANGUAGES, Pills, SourceFocusFields, type SourceSelection } from "./SourceFocusFields";
9
+ import { ConfirmDeleteModal, errorMessage, JobProgress, Modal, StatusPill } from "./ui";
10
+
11
+ /** The audio styles. The server describes each to the script writer (backend/app/pipeline/media.py STYLES). */
12
+ const STYLES = [
13
+ { id: "deep_dive", name: "Deep Dive", blurb: "A lively conversation that unpacks the ideas in your sources and connects them." },
14
+ { id: "brief", name: "Brief", blurb: "A bite sized overview to help you grasp the core ideas from your sources quickly." },
15
+ { id: "critique", name: "Critique", blurb: "An expert review of your sources, with constructive feedback to help you improve your material." },
16
+ { id: "debate", name: "Debate", blurb: "A thoughtful debate between two hosts, bringing out different perspectives on your sources." },
17
+ ] as const;
18
+ type Style = (typeof STYLES)[number]["id"];
19
+
20
+ const LENGTHS = [
21
+ { value: "3", label: "Short", hint: "about 3 min" },
22
+ { value: "6", label: "Default", hint: "about 6 min" },
23
+ { value: "12", label: "Long", hint: "about 12 min" },
24
+ ];
25
+
26
+ export type AudioOverviewRequest = Omit<GenerateBody, "type">;
27
+
28
+ /** The workspace's voices, asked for once (the list takes a few seconds): the notebook asks ahead of time, so the
29
+ * pickers are ready when the settings open. A failure is forgotten, so the next open asks again. */
30
+ let voicesAsked: Promise<VideoSavedVoice[]> | null = null;
31
+ export function prefetchVoices(): Promise<VideoSavedVoice[]> {
32
+ voicesAsked ??= videoVoicesApi.list().then((r) => r.saved).catch((e) => {
33
+ voicesAsked = null;
34
+ throw e;
35
+ });
36
+ return voicesAsked;
37
+ }
38
+
39
+ /** The audio overview settings: a style, one or two hosts and their voices, length, language, the sources, and what
40
+ * the hosts should focus on. */
41
+ export function AudioOverviewDialog({ notebookId, notebookTitle, chats, currentChatId, onClose, onCreate }: {
42
+ notebookId: string;
43
+ notebookTitle?: string;
44
+ chats: ChatSummary[];
45
+ currentChatId?: string | null;
46
+ onClose: () => void;
47
+ onCreate: (body: AudioOverviewRequest) => void;
48
+ }) {
49
+ const [sel, setSel] = useState<SourceSelection | null>(null);
50
+ const [style, setStyle] = useState<Style>("deep_dive");
51
+ const [hosts, setHosts] = useState<"1" | "2">("2");
52
+ const [minutes, setMinutes] = useState("6");
53
+ const [language, setLanguage] = useState("English");
54
+ const [focus, setFocus] = useState("");
55
+ // The hosts' voices, from the workspace's voices (none to pick when they cannot load: the default voices are used).
56
+ const [voices, setVoices] = useState<VideoSavedVoice[] | null>(null);
57
+ const [voice, setVoice] = useState<[string, string]>(["", ""]);
58
+
59
+ useEffect(() => {
60
+ let live = true;
61
+ prefetchVoices().then((saved) => {
62
+ if (!live) return;
63
+ setVoices(saved);
64
+ setVoice([saved[0]?.voice_id ?? "", (saved[1] ?? saved[0])?.voice_id ?? ""]);
65
+ }).catch(() => live && setVoices([]));
66
+ return () => {
67
+ live = false;
68
+ };
69
+ }, []);
70
+
71
+ const two = hosts === "2";
72
+ // A debate needs two hosts: with one, the style moves to Deep Dive.
73
+ useEffect(() => {
74
+ if (!two && style === "debate") setStyle("deep_dive");
75
+ }, [two, style]);
76
+
77
+ /** Pick a host's voice; the other host moves off it, so the two never share one (when there are two to pick). */
78
+ const pickVoice = (i: 0 | 1, voiceId: string) =>
79
+ setVoice((cur) => {
80
+ const next: [string, string] = [...cur];
81
+ next[i] = voiceId;
82
+ const other = i === 0 ? 1 : 0;
83
+ if (two && next[other] === voiceId) next[other] = voices?.find((v) => v.voice_id !== voiceId)?.voice_id ?? voiceId;
84
+ return next;
85
+ });
86
+
87
+
88
+ const voiceOptions = (voices ?? []).map((v) => ({ value: v.voice_id, label: v.name }));
89
+ const create = () => {
90
+ if (!sel) return;
91
+ onCreate({
92
+ ...sel.request, format: style, hosts: two ? 2 : 1, minutes: Number(minutes), language, instructions: focus.trim(),
93
+ ...(voice[0] ? { host_a: voice[0], ...(two ? { host_b: voice[1] || voice[0] } : {}) } : {}),
94
+ });
95
+ };
96
+
97
+ return (
98
+ <Modal title="Customize audio overview" onClose={onClose} wide>
99
+ <div className="stack vw-in-modal ao-dialog">
100
+ <section className="field">
101
+ <span className="vw-label">Style</span>
102
+ <div className="ao-styles" role="radiogroup" aria-label="Style">
103
+ {STYLES.map((s) => {
104
+ const off = s.id === "debate" && !two;
105
+ return (
106
+ <button key={s.id} type="button" role="radio" aria-checked={style === s.id} disabled={off}
107
+ className={`ao-style${style === s.id ? " on" : ""}`} onClick={() => setStyle(s.id)}>
108
+ <span className="ao-style-head">
109
+ <strong>{s.name}</strong>
110
+ <span className="ao-radio" aria-hidden="true">{style === s.id && <CheckIcon size={12} />}</span>
111
+ </span>
112
+ <span className="ao-style-blurb">{off ? "Needs two hosts." : s.blurb}</span>
113
+ </button>
114
+ );
115
+ })}
116
+ </div>
117
+ </section>
118
+
119
+ <section className="sd-opts ao-opts">
120
+ <div className="field">
121
+ <span className="vw-label">Hosts</span>
122
+ <Pills<"1" | "2"> label="Hosts" value={hosts} onChange={setHosts}
123
+ options={[{ id: "1", label: "One host" }, { id: "2", label: "Two hosts" }]} />
124
+ </div>
125
+ <div className="field">
126
+ <span className="vw-label">Length</span>
127
+ <Dropdown<string> label="Length" value={minutes} onChange={setMinutes} options={LENGTHS} />
128
+ </div>
129
+ <div className="field">
130
+ <span className="vw-label">Language</span>
131
+ <Dropdown<string> label="Language" value={language} onChange={setLanguage}
132
+ options={LANGUAGES.map((l) => ({ value: l, label: l }))} />
133
+ </div>
134
+ </section>
135
+
136
+ <section className="ao-voices">
137
+ {voices === null && <p className="muted small">Loading your voices...</p>}
138
+ {voices !== null && voices.length === 0 && (
139
+ <p className="muted small">Your voices could not load, so the default {two ? "voices are" : "voice is"} used.</p>
140
+ )}
141
+ {voices !== null && voices.length > 0 && ([0, 1] as const).filter((i) => i === 0 || two).map((i) => (
142
+ <div key={i} className="field">
143
+ <span className="vw-label">{two ? `Host ${i + 1} voice` : "Host voice"}</span>
144
+ <Dropdown<string> label={two ? `Host ${i + 1} voice` : "Host voice"} value={voice[i]} onChange={(v) => pickVoice(i, v)}
145
+ options={voiceOptions} />
146
+ </div>
147
+ ))}
148
+ </section>
149
+
150
+ <SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
151
+ what="audio overview" noFocus onChange={setSel} />
152
+
153
+ <label className="field">
154
+ <span className="vw-label">What should the AI {two ? "hosts" : "host"} focus on in this episode? <span className="muted">(optional)</span></span>
155
+ <textarea className="input sd-prompt" rows={4} maxLength={4000} value={focus} onChange={(e) => setFocus(e.target.value)}
156
+ placeholder={"- List the core ideas worth remembering\n- Explain how the pieces connect"} />
157
+ </label>
158
+ {!sel?.ready && <p className="muted small">Select at least one post or chat to make an audio overview.</p>}
159
+ <div className="vw-nav">
160
+ <button type="button" className="btn btn-primary vw-next" disabled={!sel?.ready} onClick={create}>
161
+ Generate audio overview
162
+ </button>
163
+ </div>
164
+ </div>
165
+ </Modal>
166
+ );
167
+ }
168
+
169
+ const SPEEDS = [1, 1.25, 1.5, 1.75, 2, 0.75];
170
+
171
+ function clock(seconds: number): string {
172
+ const s = Math.max(0, Math.floor(seconds || 0));
173
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
174
+ }
175
+
176
+ /** The audio's own player: play or pause, where it is, a bar to move through it, and the playback speed. */
177
+ function AudioPlayer({ src, duration: known, audioRef }: { src: string; duration?: number; audioRef: RefObject<HTMLAudioElement> }) {
178
+ const [playing, setPlaying] = useState(false);
179
+ const [time, setTime] = useState(0);
180
+ const [duration, setDuration] = useState(known ?? 0);
181
+ const [speed, setSpeed] = useState(1);
182
+ useEffect(() => {
183
+ const a = audioRef.current;
184
+ if (!a) return;
185
+ const sync = () => {
186
+ setTime(a.currentTime);
187
+ if (Number.isFinite(a.duration) && a.duration > 0) setDuration(a.duration);
188
+ setPlaying(!a.paused && !a.ended);
189
+ };
190
+ const events = ["timeupdate", "loadedmetadata", "durationchange", "play", "pause", "ended"];
191
+ events.forEach((e) => a.addEventListener(e, sync));
192
+ return () => events.forEach((e) => a.removeEventListener(e, sync));
193
+ }, [audioRef]);
194
+ const toggle = () => {
195
+ const a = audioRef.current;
196
+ if (!a) return;
197
+ if (a.paused) void a.play();
198
+ else a.pause();
199
+ };
200
+ const nextSpeed = () => {
201
+ const next = SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length];
202
+ setSpeed(next);
203
+ if (audioRef.current) audioRef.current.playbackRate = next;
204
+ };
205
+ const pct = duration ? Math.min(100, (time / duration) * 100) : 0;
206
+ return (
207
+ <div className="ao-player">
208
+ <audio ref={audioRef} src={src} preload="metadata" />
209
+ <button type="button" className="ao-play" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
210
+ {playing ? (
211
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>
212
+ ) : (
213
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z" /></svg>
214
+ )}
215
+ </button>
216
+ <span className="ao-time mono">{clock(time)} / {clock(duration)}</span>
217
+ <input className="ao-seek" type="range" min={0} max={duration || 0} step={0.1} value={Math.min(time, duration || 0)}
218
+ aria-label="Position" style={{ ["--ao-fill" as string]: `${pct}%` }}
219
+ onChange={(e) => {
220
+ const a = audioRef.current;
221
+ if (a) a.currentTime = Number(e.target.value);
222
+ setTime(Number(e.target.value));
223
+ }} />
224
+ <button type="button" className="ao-speed mono" onClick={nextSpeed} aria-label={`Playback speed ${speed}x`} title="Playback speed">
225
+ {speed}x
226
+ </button>
227
+ </div>
228
+ );
229
+ }
230
+
231
+ const STYLE_NAMES: Record<string, string> = Object.fromEntries(STYLES.map((s) => [s.id, s.name]));
232
+
233
+ /** An audio overview in the notebook's list: a row with its title and length that opens (chevron) to its player,
234
+ * transcript, Download and Delete. Shows its progress while it is being made. */
235
+ export function AudioOverviewCard({ artifact: initial, onRemoved }: { artifact: Artifact; onRemoved: (id: string) => void }) {
236
+ const [artifact, setArtifact] = useState(initial);
237
+ const [open, setOpen] = useState(false);
238
+ const [transcript, setTranscript] = useState(false);
239
+ const [confirming, setConfirming] = useState(false);
240
+ const [error, setError] = useState<string | null>(null);
241
+ const audio = useRef<HTMLAudioElement>(null);
242
+ const job = useJob(artifact.job, () => {
243
+ artifactsApi.get(artifact.id).then(setArtifact);
244
+ });
245
+ const running = job && (job.status === "queued" || job.status === "running");
246
+ const ready = artifact.status === "ready";
247
+ const failed = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error ?? "Could not make this audio." : null;
248
+ const c = artifact.content;
249
+ const segments = (c.segments as Segment[] | undefined) ?? [];
250
+ const meta = [
251
+ c.duration_s ? clock(c.duration_s as number) : "",
252
+ c.hosts === 1 ? "1 host" : c.hosts === 2 ? "2 hosts" : "",
253
+ STYLE_NAMES[c.format as string] ?? "",
254
+ ].filter(Boolean).join(" · ") || (running ? "Making the audio..." : "");
255
+
256
+ const remove = async () => {
257
+ await artifactsApi.remove(artifact.id);
258
+ onRemoved(artifact.id);
259
+ };
260
+
261
+ return (
262
+ <article className={`ao-card${open ? " open" : ""}`}>
263
+ <div className="ao-card-head" role={ready ? "button" : undefined} tabIndex={ready ? 0 : undefined} aria-expanded={ready ? open : undefined}
264
+ onClick={() => ready && setOpen((o) => !o)}
265
+ onKeyDown={(e) => ready && (e.key === "Enter" || e.key === " ") && (e.preventDefault(), setOpen((o) => !o))}>
266
+ <span className="ao-card-icon" aria-hidden="true">{running ? <span className="nbv-send-spinner" /> : <HeadphonesIcon size={20} />}</span>
267
+ <span className="ao-card-text">
268
+ <strong title={artifact.title}>{artifact.title}</strong>
269
+ <span className="ao-card-meta">{meta}</span>
270
+ </span>
271
+ {!ready && !failed && <StatusPill status={running ? (job!.status === "queued" ? "queued" : "working") : artifact.status} />}
272
+ {ready && (
273
+ <span className="ao-chevron" aria-hidden="true"><ChevronIcon size={18} className={`chevron${open ? " open" : ""}`} /></span>
274
+ )}
275
+ </div>
276
+ {running && job && <div className="ao-card-progress"><JobProgress job={job} compact /></div>}
277
+ {failed && (
278
+ <div className="ao-card-progress sd-item-failed">
279
+ <p className="error-text small">{failed}</p>
280
+ <button type="button" className="link-btn" onClick={async () => {
281
+ setError(null);
282
+ try {
283
+ setArtifact(await artifactsApi.retry(artifact.id));
284
+ } catch (e) {
285
+ setError(errorMessage(e));
286
+ }
287
+ }}>Retry</button>
288
+ <button type="button" className="link-btn" onClick={() => setConfirming(true)}>Remove</button>
289
+ </div>
290
+ )}
291
+ {ready && open && (
292
+ <div className="ao-card-body">
293
+ {artifact.url && <AudioPlayer src={artifact.url} duration={c.duration_s as number | undefined} audioRef={audio} />}
294
+ {segments.length > 0 && (
295
+ <button type="button" className="link-btn ao-transcript-toggle" aria-expanded={transcript} onClick={() => setTranscript((t) => !t)}>
296
+ {transcript ? "Hide transcript" : `Show transcript (${segments.length} lines)`}
297
+ </button>
298
+ )}
299
+ {transcript && (
300
+ <Transcript segments={segments} onSeek={(t) => {
301
+ const a = audio.current;
302
+ if (!a) return;
303
+ a.currentTime = t;
304
+ void a.play();
305
+ }} />
306
+ )}
307
+ <div className="row ao-card-actions">
308
+ {artifact.download_url && <a className="btn btn-small" href={artifact.download_url}>Download</a>}
309
+ <button type="button" className="btn btn-small ao-delete" onClick={() => setConfirming(true)}>Delete</button>
310
+ </div>
311
+ </div>
312
+ )}
313
+ {error && <p className="error-text small ao-card-progress">{error}</p>}
314
+ {confirming && <ConfirmDeleteModal heading="Delete audio overview?" name={artifact.title} onCancel={() => setConfirming(false)}
315
+ onConfirm={remove} />}
316
+ </article>
317
+ );
318
+ }
frontend/src/components/PaneResizer.tsx ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
2
+
3
+ /** The notebook's side panels, resized by dragging the line between a panel and the chat. Widths are kept per browser;
4
+ * nothing set means the stylesheet's default width. A panel never goes under its minimum, and the chat always keeps
5
+ * room, so a narrower window simply takes the extra width back. */
6
+
7
+ export const PANE_MIN = { left: 220, right: 300 } as const;
8
+ const CHAT_MIN = 440;
9
+ const GUTTER = 16; // the space between the chat and the right panel (.nbv-side's margin)
10
+ const KEYS = { left: "ns_nbv_left", right: "ns_nbv_right" } as const;
11
+
12
+ type Side = "left" | "right";
13
+ type Widths = { left: number | null; right: number | null };
14
+
15
+ function read(side: Side): number | null {
16
+ try {
17
+ const v = Number(localStorage.getItem(KEYS[side]));
18
+ return Number.isFinite(v) && v > 0 ? v : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function write(side: Side, value: number | null) {
25
+ try {
26
+ if (value === null) localStorage.removeItem(KEYS[side]);
27
+ else localStorage.setItem(KEYS[side], String(Math.round(value)));
28
+ } catch {
29
+ /* Private browsing can prevent persistence; resizing still works for this visit. */
30
+ }
31
+ }
32
+
33
+ function useMedia(query: string): boolean {
34
+ const [on, setOn] = useState(() => typeof window !== "undefined" && window.matchMedia(query).matches);
35
+ useEffect(() => {
36
+ const m = window.matchMedia(query);
37
+ const change = () => setOn(m.matches);
38
+ change();
39
+ m.addEventListener("change", change);
40
+ return () => m.removeEventListener("change", change);
41
+ }, [query]);
42
+ return on;
43
+ }
44
+
45
+ /** Where the panes are now: the container's width and the two boundaries (px from its left edge). */
46
+ type Frame = { width: number; leftEdge: number | null; rightEdge: number | null };
47
+
48
+ export function usePaneWidths(container: RefObject<HTMLElement | null>, opts: { leftOpen: boolean }) {
49
+ const [saved, setSaved] = useState<Widths>(() => ({ left: read("left"), right: read("right") }));
50
+ const [frame, setFrame] = useState<Frame>({ width: 0, leftEdge: null, rightEdge: null });
51
+ // The left panel sits beside the chat from 900px; the right one from 1350px (below that it stacks under the chat).
52
+ const leftActive = useMedia("(min-width: 900px)") && opts.leftOpen;
53
+ const rightActive = useMedia("(min-width: 1350px)");
54
+
55
+ // Measure the real boundaries after layout changes, so the handles sit exactly on them (state only changes when a
56
+ // number does, so this cannot loop).
57
+ const measure = useCallback(() => {
58
+ const el = container.current;
59
+ if (!el) return;
60
+ const box = el.getBoundingClientRect();
61
+ const history = el.querySelector<HTMLElement>(":scope > .nbv-history");
62
+ const side = el.querySelector<HTMLElement>(":scope > .nbv-side");
63
+ const next: Frame = {
64
+ width: Math.round(box.width),
65
+ leftEdge: history ? Math.round(history.getBoundingClientRect().right - box.left) : null,
66
+ rightEdge: side ? Math.round(side.getBoundingClientRect().left - box.left - GUTTER / 2) : null,
67
+ };
68
+ setFrame((cur) => (cur.width === next.width && cur.leftEdge === next.leftEdge && cur.rightEdge === next.rightEdge ? cur : next));
69
+ }, [container]);
70
+ useLayoutEffect(() => {
71
+ measure();
72
+ });
73
+ useEffect(() => {
74
+ const el = container.current;
75
+ if (!el || typeof ResizeObserver === "undefined") return;
76
+ const ro = new ResizeObserver(measure);
77
+ ro.observe(el);
78
+ el.querySelectorAll(":scope > .nbv-history, :scope > .nbv-side").forEach((p) => ro.observe(p));
79
+ window.addEventListener("resize", measure);
80
+ return () => {
81
+ ro.disconnect();
82
+ window.removeEventListener("resize", measure);
83
+ };
84
+ }, [container, measure, leftActive, rightActive]);
85
+
86
+ const { width } = frame;
87
+ // What each panel measures by default (the stylesheet's width), when nothing is saved for it.
88
+ const measuredLeft = frame.leftEdge ?? 0;
89
+ const measuredRight = frame.rightEdge !== null ? width - frame.rightEdge - GUTTER / 2 : 0;
90
+ const wantLeft = leftActive ? (saved.left ?? measuredLeft) : 0;
91
+ const wantRight = rightActive ? (saved.right ?? measuredRight) : 0;
92
+ // Each panel: at least its minimum, and never so wide that the chat gets less than CHAT_MIN.
93
+ const maxLeft = Math.max(PANE_MIN.left, width - CHAT_MIN - (rightActive ? wantRight + GUTTER : 0));
94
+ const maxRight = Math.max(PANE_MIN.right, width - CHAT_MIN - GUTTER - (leftActive ? wantLeft : 0));
95
+ const fit = (v: number, max: number, min: number) => Math.max(min, Math.min(v, max));
96
+ const left = leftActive && saved.left !== null && width ? fit(saved.left, maxLeft, PANE_MIN.left) : null;
97
+ const right = rightActive && saved.right !== null && width ? fit(saved.right, maxRight, PANE_MIN.right) : null;
98
+
99
+ const set = useCallback((side: Side, value: number | null, persist = false) => {
100
+ setSaved((cur) => ({ ...cur, [side]: value }));
101
+ if (persist) write(side, value);
102
+ }, []);
103
+
104
+ const style: CSSProperties = {
105
+ ...(left !== null ? { ["--nbv-left" as string]: `${Math.round(left)}px` } : {}),
106
+ ...(right !== null ? { ["--nbv-right" as string]: `${Math.round(right + GUTTER)}px` } : {}),
107
+ };
108
+
109
+ return {
110
+ style,
111
+ frame,
112
+ active: { left: leftActive && frame.leftEdge !== null, right: rightActive && frame.rightEdge !== null },
113
+ current: { left: left ?? measuredLeft, right: right ?? measuredRight },
114
+ bounds: { left: { min: PANE_MIN.left, max: maxLeft }, right: { min: PANE_MIN.right, max: maxRight } },
115
+ set,
116
+ };
117
+ }
118
+
119
+ type Panes = ReturnType<typeof usePaneWidths>;
120
+
121
+ /** The line between a side panel and the chat: drag it (or focus it and use the arrow keys) to resize the panel;
122
+ * double click puts the panel back to its default width. */
123
+ export function PaneResizer({ side, panes, container, label }: {
124
+ side: Side;
125
+ panes: Panes;
126
+ container: RefObject<HTMLElement | null>;
127
+ label: string;
128
+ }) {
129
+ const [dragging, setDragging] = useState(false);
130
+ const frameReq = useRef(0);
131
+ const { min, max } = panes.bounds[side];
132
+ const value = Math.round(panes.current[side]);
133
+ const clamp = (v: number) => Math.max(min, Math.min(max, v));
134
+ const at = side === "left" ? panes.frame.leftEdge : panes.frame.rightEdge;
135
+
136
+ const fromPointer = (clientX: number) => {
137
+ const box = container.current?.getBoundingClientRect();
138
+ if (!box) return value;
139
+ return clamp(side === "left" ? clientX - box.left : box.right - clientX - GUTTER / 2);
140
+ };
141
+
142
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
143
+ if (e.button !== 0) return;
144
+ e.preventDefault();
145
+ e.currentTarget.setPointerCapture(e.pointerId);
146
+ setDragging(true);
147
+ document.body.classList.add("nbv-resizing");
148
+ };
149
+ const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
150
+ if (!dragging) return;
151
+ const x = e.clientX;
152
+ cancelAnimationFrame(frameReq.current);
153
+ frameReq.current = requestAnimationFrame(() => panes.set(side, fromPointer(x)));
154
+ };
155
+ const end = (e: React.PointerEvent<HTMLDivElement>) => {
156
+ if (!dragging) return;
157
+ cancelAnimationFrame(frameReq.current);
158
+ setDragging(false);
159
+ document.body.classList.remove("nbv-resizing");
160
+ panes.set(side, fromPointer(e.clientX), true);
161
+ };
162
+ useEffect(() => () => document.body.classList.remove("nbv-resizing"), []);
163
+
164
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
165
+ const step = e.shiftKey ? 64 : 16;
166
+ // The left panel grows to the right; the right panel grows to the left.
167
+ const grow = side === "left" ? "ArrowRight" : "ArrowLeft";
168
+ const shrink = side === "left" ? "ArrowLeft" : "ArrowRight";
169
+ let next: number | null = null;
170
+ if (e.key === grow) next = value + step;
171
+ else if (e.key === shrink) next = value - step;
172
+ else if (e.key === "Home") next = min;
173
+ else if (e.key === "End") next = max;
174
+ else return;
175
+ e.preventDefault();
176
+ panes.set(side, clamp(next), true);
177
+ };
178
+
179
+ if (!panes.active[side] || at === null) return null;
180
+ return (
181
+ <div className={`nbv-resizer nbv-resizer-${side}${dragging ? " dragging" : ""}`} style={{ left: at }}
182
+ role="separator" aria-orientation="vertical" aria-label={label} tabIndex={0}
183
+ aria-valuenow={value} aria-valuemin={min} aria-valuemax={Math.round(max)}
184
+ title="Drag to resize. Double click to reset."
185
+ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={end} onPointerCancel={end}
186
+ onDoubleClick={() => panes.set(side, null, true)} onKeyDown={onKeyDown} />
187
+ );
188
+ }
frontend/src/components/ScheduleModal.tsx CHANGED
@@ -79,7 +79,8 @@ export function ScheduleModal({
79
  if (artifactId && !artifact) {
80
  launchpadApi.postable().then((list) => {
81
  const found = list.find((a) => a.id === artifactId);
82
- if (found && found.type !== "launch_kit") attach(found); // a kit is never an attachment
 
83
  }, () => undefined);
84
  }
85
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -151,8 +152,9 @@ export function ScheduleModal({
151
  const account = meta.auto ? accountFor(p) : null;
152
  const item = await launchpadApi.create({
153
  platform: p,
154
- // Post now: this instant. Scheduled: the half hour picked, in the user's zone (the backend makes it UTC).
155
- ...(now ? { scheduled_at: new Date().toISOString() } : { local_time: when, timezone: userTimeZone() }),
 
156
  content: merged[0] ?? "",
157
  thread: merged.slice(1),
158
  // Images and videos only go to X and LinkedIn; text attachments go anywhere.
@@ -162,8 +164,7 @@ export function ScheduleModal({
162
  social_account_id: account?.id ?? null,
163
  remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account,
164
  });
165
- const out = now && item.auto_post ? await launchpadApi.publishNow(item.id) : item;
166
- onScheduled?.(out);
167
  done.push(meta.label);
168
  }
169
  onClose();
 
79
  if (artifactId && !artifact) {
80
  launchpadApi.postable().then((list) => {
81
  const found = list.find((a) => a.id === artifactId);
82
+ // a kit is never an attachment, and audio can't be posted (X and LinkedIn take no audio files)
83
+ if (found && found.type !== "launch_kit" && found.media !== "audio") attach(found);
84
  }, () => undefined);
85
  }
86
  // eslint-disable-next-line react-hooks/exhaustive-deps
 
152
  const account = meta.auto ? accountFor(p) : null;
153
  const item = await launchpadApi.create({
154
  platform: p,
155
+ // Post now: published by this very request (the server takes the time). Scheduled: the half hour picked, in
156
+ // the user's zone (the backend makes it UTC).
157
+ ...(now ? { publish_now: true } : { local_time: when, timezone: userTimeZone() }),
158
  content: merged[0] ?? "",
159
  thread: merged.slice(1),
160
  // Images and videos only go to X and LinkedIn; text attachments go anywhere.
 
164
  social_account_id: account?.id ?? null,
165
  remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account,
166
  });
167
+ onScheduled?.(item);
 
168
  done.push(meta.label);
169
  }
170
  onClose();
frontend/src/components/SlideDeck.tsx ADDED
@@ -0,0 +1,1054 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { artifactsApi, type EditSlide, type GenerateBody } from "../api/endpoints";
4
+ import { addActions, convert, type DeckFmt, LAYOUTS, MAX_SLIDES, newSlide, problem, removable, removeField, setText } from "./slideDeckEdit";
5
+ import type { Artifact, ChatSummary } from "../api/types";
6
+ import { Dropdown } from "./Dropdown";
7
+ import { useJob } from "../hooks/useJob";
8
+ import { CheckIcon, CollapseIcon, DownloadIcon, ExpandIcon, MoreIcon, PdfIcon, PlayIcon, PptxIcon, SlidesIcon, TrashIcon } from "./icons/Icons";
9
+ import { LANGUAGES, Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
10
+ import { ConfirmDeleteModal, errorMessage, formatDate, JobProgress, Modal } from "./ui";
11
+
12
+ /** Bumped when the thumbnails are drawn again (backend/scripts/render_slide_thumbnails.py), so browsers fetch the new
13
+ * images instead of the ones they cached. */
14
+ const THUMBS_VERSION = 2;
15
+
16
+ /** The two deck themes. Their slides are laid out on the server (backend/app/slides); the thumbnails are rendered from
17
+ * them (backend/scripts/render_slide_thumbnails.py). */
18
+ export const SLIDE_THEMES = [
19
+ { id: "dark-space", name: "Night stellar", blurb: "Night sky, planets and a blue glow" },
20
+ { id: "light-space", name: "Moon light", blurb: "Moonlit sky, orbits and constellations" },
21
+ ] as const;
22
+
23
+ const FORMATS = [
24
+ { id: "detailed", label: "Detailed deck", blurb: "Full text and details, perfect for emailing or reading on its own." },
25
+ { id: "presenter", label: "Presenter slides", blurb: "Clean slides with key talking points to support you while you speak." },
26
+ ] as const;
27
+
28
+ const LENGTHS = [
29
+ { id: "short", label: "Short", range: "6 to 8 slides" },
30
+ { id: "default", label: "Default", range: "9 to 11 slides" },
31
+ { id: "long", label: "Long", range: "12 to 16 slides" },
32
+ ] as const;
33
+
34
+ const SLIDE_W = 1920;
35
+ const SLIDE_H = 1080;
36
+
37
+ export type SlideDeckRequest = Omit<GenerateBody, "type">;
38
+ type DeckFormat = "detailed" | "presenter";
39
+ type DeckLength = "short" | "default" | "long";
40
+
41
+ /** The slide deck settings: a visual style on top, then the format, language, length, sources and what it should say. */
42
+ export function SlideDeckDialog({ notebookId, notebookTitle, chats, currentChatId, onClose, onCreate }: {
43
+ notebookId: string;
44
+ notebookTitle?: string;
45
+ chats: ChatSummary[];
46
+ currentChatId?: string | null;
47
+ onClose: () => void;
48
+ onCreate: (body: SlideDeckRequest) => void;
49
+ }) {
50
+ const [sel, setSel] = useState<SourceSelection | null>(null);
51
+ const [theme, setTheme] = useState<string>("dark-space");
52
+ const [format, setFormat] = useState<DeckFormat>("detailed");
53
+ const [length, setLength] = useState<DeckLength>("default");
54
+ const [language, setLanguage] = useState("English");
55
+ const [prompt, setPrompt] = useState("");
56
+
57
+ return (
58
+ <Modal title="Customize slide deck" onClose={onClose} wide>
59
+ <div className="stack vw-in-modal sd-dialog">
60
+ <section className="field">
61
+ <span className="vw-label">Visual style</span>
62
+ <div className="sd-themes" role="radiogroup" aria-label="Visual style">
63
+ {SLIDE_THEMES.map((t) => (
64
+ <button key={t.id} type="button" role="radio" aria-checked={theme === t.id} title={t.blurb}
65
+ className={`sd-theme${theme === t.id ? " on" : ""}`} onClick={() => setTheme(t.id)}>
66
+ <span className="sd-theme-img">
67
+ <img src={`/slides/${t.id}.png?v=${THUMBS_VERSION}`} alt="" loading="lazy" />
68
+ {theme === t.id && <span className="sd-check" aria-hidden="true"><CheckIcon size={12} /></span>}
69
+ </span>
70
+ <span className="sd-theme-name">{t.name}</span>
71
+ </button>
72
+ ))}
73
+ </div>
74
+ </section>
75
+
76
+ <section className="sd-opts">
77
+ <div className="field">
78
+ <span className="vw-label">Format</span>
79
+ <Pills<DeckFormat> label="Format" value={format} onChange={setFormat} options={FORMATS} />
80
+ <small className="muted">{FORMATS.find((f) => f.id === format)?.blurb}</small>
81
+ </div>
82
+ <div className="field">
83
+ <span className="vw-label">Length</span>
84
+ <Dropdown<DeckLength> label="Length" value={length} onChange={setLength}
85
+ options={LENGTHS.map((l) => ({ value: l.id, label: l.label, hint: l.range }))} />
86
+ </div>
87
+ <div className="field">
88
+ <span className="vw-label">Language</span>
89
+ <Dropdown<string> label="Language" value={language} onChange={setLanguage}
90
+ options={LANGUAGES.map((l) => ({ value: l, label: l }))} />
91
+ </div>
92
+ </section>
93
+
94
+ <SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
95
+ what="slide deck" noFocus onChange={setSel} />
96
+
97
+ <label className="field">
98
+ <span className="vw-label">Describe the slide deck you want to create <span className="muted">(optional)</span></span>
99
+ <textarea className="input sd-prompt" rows={3} maxLength={4000} value={prompt} onChange={(e) => setPrompt(e.target.value)}
100
+ placeholder={'Add a high-level outline, or guide the audience, style, and focus: "Create a deck for beginners with a focus on step-by-step instructions."'} />
101
+ </label>
102
+
103
+ {!sel?.ready && <p className="muted small">Select at least one post or chat to make a slide deck.</p>}
104
+ <div className="vw-nav">
105
+ <button type="button" className="btn btn-primary vw-next" disabled={!sel?.ready}
106
+ onClick={() => sel && onCreate({ ...sel.request, theme, deck_format: format, deck_length: length,
107
+ language, instructions: prompt.trim() })}>
108
+ Generate slide deck
109
+ </button>
110
+ </div>
111
+ </div>
112
+ </Modal>
113
+ );
114
+ }
115
+
116
+ /** The editor that runs inside an editable slide's frame. The frame is sandboxed with allow-scripts but not
117
+ * allow-same-origin, so this script (and anything else in the slide) can never reach this page: the two only talk by
118
+ * postMessage. The page says when edit mode is on and which text boxes may be removed ({type: "sd-mode", editing,
119
+ * removable}); the frame reports each edit ({type: "sd-edit", field, text}), a box's remove button
120
+ * ({type: "sd-remove", field}) and that it is ready ({type: "sd-ready"}). Self-contained: it is sent as source.
121
+ *
122
+ * In edit mode the spans the server marks with data-f (and data-max) are editable, single line, plain text, no longer
123
+ * than the field holds; every text box is framed (dashed, solid on hover, lit while typing), and a click anywhere in a
124
+ * box types into it. Sizes are slide pixels: the slide is drawn 1920 wide and scaled down. */
125
+ function slideEditor() {
126
+ const css = `.sd-box{outline:3px dashed rgba(33,124,255,.8);outline-offset:10px;border-radius:6px;cursor:text;
127
+ background-color:rgba(33,124,255,.07);transition:outline-color .12s,box-shadow .12s}
128
+ .sd-box:hover{outline-style:solid}
129
+ .sd-box:focus-within{outline:4px solid #217cff;box-shadow:0 0 0 14px rgba(33,124,255,.22)}
130
+ [data-f]{outline:none;caret-color:#217cff}
131
+ [data-f]:empty{display:inline-block;min-width:1.2em;min-height:1em;background:rgba(255,93,93,.25)}
132
+ .sd-x{position:absolute;top:-34px;right:-34px;z-index:2;width:48px;height:48px;padding:0;border:3px solid #fff;border-radius:50%;
133
+ background:#e5484d;color:#fff;font:700 30px/1 Inter,system-ui,sans-serif;cursor:pointer;opacity:.45;transition:opacity .12s;
134
+ box-shadow:0 4px 14px rgba(0,0,0,.35)}
135
+ .sd-box:hover .sd-x,.sd-box:focus-within .sd-x{opacity:1}
136
+ .sd-x:hover{background:#c9353a}`;
137
+ const spans = Array.from(document.querySelectorAll<HTMLElement>("[data-f]"));
138
+ const boxes = Array.from(new Set(spans.map((el) => el.closest<HTMLElement>(".e")).filter((b): b is HTMLElement => !!b)));
139
+ const style = document.createElement("style");
140
+ style.textContent = css;
141
+ let on = false;
142
+ const field = (t: EventTarget | null) => {
143
+ const el = t as HTMLElement | null;
144
+ return on && el && el.dataset && el.dataset.f ? el : null;
145
+ };
146
+ const room = (el: HTMLElement) => {
147
+ const sel = document.getSelection();
148
+ const picked = sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.toString().length : 0;
149
+ return Number(el.dataset.max || 9999) - ((el.textContent || "").length - picked);
150
+ };
151
+ let removers: HTMLElement[] = [];
152
+ const setMode = (editing: boolean, removable: string[]) => {
153
+ on = editing;
154
+ removers.forEach((x) => x.remove());
155
+ removers = [];
156
+ for (const el of spans) {
157
+ if (!editing) {
158
+ el.removeAttribute("contenteditable");
159
+ continue;
160
+ }
161
+ try {
162
+ el.contentEditable = "plaintext-only";
163
+ } catch {
164
+ el.contentEditable = "true"; // no plaintext-only: paste is made plain below
165
+ }
166
+ el.spellcheck = true;
167
+ }
168
+ boxes.forEach((b) => b.classList.toggle("sd-box", editing));
169
+ // One remove button per box that may go: for a point's box, the point itself (its text) before its title.
170
+ if (editing) {
171
+ for (const b of boxes) {
172
+ const fields = Array.from(b.querySelectorAll<HTMLElement>("[data-f]")).map((el) => el.dataset.f || "");
173
+ const f = fields.find((x) => x.endsWith(".text") && removable.includes(x)) || fields.find((x) => removable.includes(x));
174
+ if (!f) continue;
175
+ const x = document.createElement("button");
176
+ x.className = "sd-x";
177
+ x.type = "button";
178
+ x.textContent = "×";
179
+ x.title = "Remove this text box";
180
+ x.setAttribute("aria-label", "Remove this text box");
181
+ x.contentEditable = "false";
182
+ x.dataset.remove = f;
183
+ b.appendChild(x);
184
+ removers.push(x);
185
+ }
186
+ }
187
+ if (editing) document.head.appendChild(style);
188
+ else {
189
+ style.remove();
190
+ const sel = document.getSelection();
191
+ if (sel) sel.removeAllRanges();
192
+ }
193
+ };
194
+ // A click in a box but not on its text (the box is often larger than the words): type at the end of the nearest text.
195
+ document.addEventListener("mousedown", (e) => {
196
+ const t = e.target as HTMLElement | null;
197
+ if (!on || !t || field(t) || !t.closest) return;
198
+ const x = t.closest<HTMLElement>(".sd-x");
199
+ if (x) {
200
+ e.preventDefault();
201
+ parent.postMessage({ type: "sd-remove", field: x.dataset.remove }, "*");
202
+ return;
203
+ }
204
+ const box = t.closest<HTMLElement>(".sd-box");
205
+ if (!box) return;
206
+ let near: HTMLElement | null = null;
207
+ let best = Infinity;
208
+ box.querySelectorAll<HTMLElement>("[data-f]").forEach((el) => {
209
+ const r = el.getBoundingClientRect();
210
+ const d = Math.hypot(Math.max(r.left - e.clientX, 0, e.clientX - r.right), Math.max(r.top - e.clientY, 0, e.clientY - r.bottom));
211
+ if (d < best) [near, best] = [el, d];
212
+ });
213
+ if (!near) return;
214
+ e.preventDefault();
215
+ const el: HTMLElement = near;
216
+ el.focus();
217
+ const range = document.createRange();
218
+ range.selectNodeContents(el);
219
+ range.collapse(false);
220
+ const sel = document.getSelection();
221
+ if (sel) {
222
+ sel.removeAllRanges();
223
+ sel.addRange(range);
224
+ }
225
+ });
226
+ document.addEventListener("beforeinput", (e) => {
227
+ const el = field(e.target);
228
+ if (!el) return;
229
+ if (e.inputType === "insertParagraph" || e.inputType === "insertLineBreak") {
230
+ e.preventDefault(); // one line: Enter is done
231
+ el.blur();
232
+ } else if (e.inputType === "insertText" && e.data && e.data.length > room(el)) {
233
+ e.preventDefault();
234
+ }
235
+ });
236
+ document.addEventListener("paste", (e) => {
237
+ const el = field(e.target);
238
+ if (!el) return;
239
+ e.preventDefault();
240
+ const text = ((e.clipboardData && e.clipboardData.getData("text/plain")) || "").replace(/\s+/g, " ").slice(0, Math.max(room(el), 0));
241
+ if (text) document.execCommand("insertText", false, text);
242
+ });
243
+ document.addEventListener("drop", (e) => {
244
+ if (field(e.target)) e.preventDefault(); // dragged text could bring markup or overflow
245
+ });
246
+ document.addEventListener("input", (e) => {
247
+ const el = field(e.target);
248
+ if (el) parent.postMessage({ type: "sd-edit", field: el.dataset.f, text: el.textContent || "" }, "*");
249
+ });
250
+ document.addEventListener("keydown", (e) => {
251
+ const el = field(e.target);
252
+ if (el && e.key === "Escape") el.blur();
253
+ });
254
+ window.addEventListener("message", (e) => {
255
+ if (e.source === parent && e.data && e.data.type === "sd-mode") {
256
+ setMode(!!e.data.editing, Array.isArray(e.data.removable) ? e.data.removable : []);
257
+ }
258
+ });
259
+ parent.postMessage({ type: "sd-ready" }, "*");
260
+ }
261
+
262
+ const EDITOR_TAG = `<script>(${slideEditor.toString()})()</script>`;
263
+
264
+ /** One slide (HTML from our own layout, every word escaped) in a sandboxed frame, scaled to fit the box it is given.
265
+ * With `onEdit` the frame also runs the slide editor (still isolated from this page: an opaque origin), and while
266
+ * `editing` its text is edited in place. */
267
+ export function SlideFrame({ html, title, fit = "width", hidden = false, editing = false, onEdit, removable, onRemove }: {
268
+ html: string;
269
+ title: string;
270
+ /** "width": as wide as the box, 16:9. "contain": as large as fits in the box both ways (presenting). */
271
+ fit?: "width" | "contain";
272
+ hidden?: boolean;
273
+ editing?: boolean;
274
+ /** Text was edited: where it lives on the slide ("heading", "points.2.text") and what it says now. */
275
+ onEdit?: (field: string, text: string) => void;
276
+ /** The text boxes that get a remove button in edit mode, and what a click on one does. */
277
+ removable?: string[];
278
+ onRemove?: (field: string) => void;
279
+ }) {
280
+ const box = useRef<HTMLDivElement>(null);
281
+ const frame = useRef<HTMLIFrameElement>(null);
282
+ const [scale, setScale] = useState(0.2);
283
+ const editRef = useRef(onEdit);
284
+ editRef.current = onEdit;
285
+ const editingRef = useRef(editing);
286
+ editingRef.current = editing;
287
+ const removeRef = useRef(onRemove);
288
+ removeRef.current = onRemove;
289
+ const removableKey = (removable ?? []).join(",");
290
+ const removableRef = useRef(removable ?? []);
291
+ removableRef.current = removable ?? [];
292
+ const withEditor = !!onEdit;
293
+ const doc = withEditor ? html.replace("</body>", `${EDITOR_TAG}</body>`) : html;
294
+ const tell = (on: boolean) =>
295
+ frame.current?.contentWindow?.postMessage({ type: "sd-mode", editing: on, removable: removableRef.current }, "*");
296
+ useEffect(() => {
297
+ if (!withEditor) return;
298
+ const onMessage = (e: MessageEvent) => {
299
+ if (e.source !== frame.current?.contentWindow || !e.data || typeof e.data !== "object") return;
300
+ if (e.data.type === "sd-ready") tell(editingRef.current); // loaded (again): in the mode the page is in
301
+ else if (e.data.type === "sd-edit" && typeof e.data.field === "string" && typeof e.data.text === "string") {
302
+ if (editingRef.current) editRef.current?.(e.data.field, e.data.text);
303
+ } else if (e.data.type === "sd-remove" && typeof e.data.field === "string" && removableRef.current.includes(e.data.field)) {
304
+ if (editingRef.current) removeRef.current?.(e.data.field);
305
+ }
306
+ };
307
+ window.addEventListener("message", onMessage);
308
+ return () => window.removeEventListener("message", onMessage);
309
+ }, [withEditor]);
310
+ useEffect(() => {
311
+ if (withEditor) tell(editing);
312
+ }, [editing, withEditor, removableKey]);
313
+ useEffect(() => {
314
+ const el = box.current;
315
+ if (!el) return;
316
+ const measure = () => {
317
+ const s = fit === "contain" ? Math.min(el.clientWidth / SLIDE_W, el.clientHeight / SLIDE_H) : el.clientWidth / SLIDE_W;
318
+ if (s > 0) setScale(s);
319
+ };
320
+ measure();
321
+ if (typeof ResizeObserver === "undefined") return;
322
+ const ro = new ResizeObserver(measure);
323
+ ro.observe(el);
324
+ return () => ro.disconnect();
325
+ }, [fit]);
326
+ return (
327
+ <div ref={box} className={`sd-frame sd-frame-${fit}${editing ? " sd-frame-edit" : ""}`}
328
+ style={hidden ? { visibility: "hidden" } : undefined} aria-hidden={hidden}>
329
+ <iframe ref={frame} title={title} srcDoc={doc} sandbox={withEditor ? "allow-scripts" : ""} tabIndex={editing ? undefined : -1}
330
+ width={SLIDE_W} height={SLIDE_H} style={{ transform: `translate(-50%, -50%) scale(${scale})` }} />
331
+ </div>
332
+ );
333
+ }
334
+
335
+ type DeckSlide = { layout: string; heading: string; notes?: string; sources?: { title?: string; path: string; line_start: number; line_end: number }[] };
336
+
337
+ function deckOf(artifact: Artifact): { pages: string[]; slides: DeckSlide[] } {
338
+ const c = artifact.content;
339
+ const pages = Array.isArray(c.slides_html) ? (c.slides_html as string[]) : [];
340
+ const slides = Array.isArray(c.deck?.slides) ? (c.deck.slides as DeckSlide[]) : [];
341
+ return { pages, slides };
342
+ }
343
+
344
+ /** Playing the deck: full screen, one slide at a time. Arrows, Space, Page Up/Down, Home and End move; a click goes on;
345
+ * Escape (or leaving full screen) stops. Every slide is loaded at once and only the current one shown, so moving between
346
+ * them never flashes. */
347
+ export function SlidePresenter({ artifact, start = 0, onClose }: { artifact: Artifact; start?: number; onClose: () => void }) {
348
+ const { pages } = deckOf(artifact);
349
+ const [i, setI] = useState(Math.min(start, Math.max(pages.length - 1, 0)));
350
+ const [idle, setIdle] = useState(false);
351
+ const root = useRef<HTMLDivElement>(null);
352
+ const timer = useRef<number | undefined>(undefined);
353
+ const dark = artifact.content.theme !== "light-space";
354
+
355
+ const go = useCallback((to: number) => setI(Math.max(0, Math.min(pages.length - 1, to))), [pages.length]);
356
+
357
+ useEffect(() => {
358
+ const el = root.current;
359
+ // Full screen when the browser allows it (not iOS Safari): the overlay already fills the window either way.
360
+ if (el?.requestFullscreen) el.requestFullscreen().catch(() => undefined);
361
+ const onChange = () => {
362
+ if (!document.fullscreenElement) onClose();
363
+ };
364
+ document.addEventListener("fullscreenchange", onChange);
365
+ return () => {
366
+ document.removeEventListener("fullscreenchange", onChange);
367
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined);
368
+ };
369
+ }, [onClose]);
370
+
371
+ useEffect(() => {
372
+ const onKey = (e: KeyboardEvent) => {
373
+ if (["ArrowRight", "ArrowDown", "PageDown", " ", "Enter"].includes(e.key)) go(i + 1);
374
+ else if (["ArrowLeft", "ArrowUp", "PageUp", "Backspace"].includes(e.key)) go(i - 1);
375
+ else if (e.key === "Home") go(0);
376
+ else if (e.key === "End") go(pages.length - 1);
377
+ else if (e.key === "Escape") onClose();
378
+ else return;
379
+ // Ours alone: the viewer (and its modal) underneath must not move or close too.
380
+ e.preventDefault();
381
+ e.stopImmediatePropagation();
382
+ };
383
+ document.addEventListener("keydown", onKey, true);
384
+ return () => document.removeEventListener("keydown", onKey, true);
385
+ }, [go, i, pages.length, onClose]);
386
+
387
+ const wake = () => {
388
+ setIdle(false);
389
+ window.clearTimeout(timer.current);
390
+ timer.current = window.setTimeout(() => setIdle(true), 2200);
391
+ };
392
+ useEffect(() => {
393
+ wake();
394
+ return () => window.clearTimeout(timer.current);
395
+ }, []);
396
+
397
+ return createPortal(
398
+ <div ref={root} className={`sd-present${dark ? "" : " light"}${idle ? " idle" : ""}`} role="dialog" aria-modal="true"
399
+ aria-label={`${artifact.title}, slide ${i + 1} of ${pages.length}`} onMouseMove={wake}
400
+ onClick={(e) => (e.target as HTMLElement).closest(".sd-present-bar") ? undefined : go(i + 1)}>
401
+ <div className="sd-present-stage">
402
+ {pages.map((html, k) => (
403
+ <SlideFrame key={k} html={html} title={`Slide ${k + 1}`} fit="contain" hidden={k !== i} />
404
+ ))}
405
+ </div>
406
+ <div className="sd-present-bar" role="toolbar" aria-label="Presenting">
407
+ <button type="button" className="btn btn-small" onClick={() => go(i - 1)} disabled={i === 0} aria-label="Previous slide">Prev</button>
408
+ <span className="sd-count" aria-live="polite">{i + 1} / {pages.length}</span>
409
+ <button type="button" className="btn btn-small" onClick={() => go(i + 1)} disabled={i === pages.length - 1} aria-label="Next slide">Next</button>
410
+ <button type="button" className="btn btn-small" onClick={onClose}>Exit</button>
411
+ </div>
412
+ </div>,
413
+ document.body,
414
+ );
415
+ }
416
+
417
+ /** A small menu under an icon button. Closes on a click outside or Escape (an Escape that closes the menu does not
418
+ * also close the popup it sits in). */
419
+ function useMenu() {
420
+ const [open, setOpen] = useState(false);
421
+ const ref = useRef<HTMLDivElement>(null); // the button's wrapper
422
+ const menuRef = useRef<HTMLDivElement>(null); // the floating menu (rendered in <body>)
423
+ useEffect(() => {
424
+ if (!open) return;
425
+ const inside = (t: EventTarget | null) => !!t && (ref.current?.contains(t as Node) || menuRef.current?.contains(t as Node));
426
+ const onDown = (e: MouseEvent) => {
427
+ if (!inside(e.target)) setOpen(false);
428
+ };
429
+ const onKey = (e: KeyboardEvent) => {
430
+ if (e.key !== "Escape") return;
431
+ e.stopImmediatePropagation();
432
+ setOpen(false);
433
+ };
434
+ // The menu floats at a fixed place: scrolling what is under it (not the menu itself) closes it.
435
+ const onScroll = (e: Event) => {
436
+ if (!inside(e.target)) setOpen(false);
437
+ };
438
+ const onResize = () => setOpen(false);
439
+ document.addEventListener("mousedown", onDown);
440
+ document.addEventListener("keydown", onKey, true);
441
+ window.addEventListener("scroll", onScroll, true);
442
+ window.addEventListener("resize", onResize);
443
+ return () => {
444
+ document.removeEventListener("mousedown", onDown);
445
+ document.removeEventListener("keydown", onKey, true);
446
+ window.removeEventListener("scroll", onScroll, true);
447
+ window.removeEventListener("resize", onResize);
448
+ };
449
+ }, [open]);
450
+ return { open, setOpen, ref, menuRef };
451
+ }
452
+
453
+ /** A menu under its button, drawn in <body> so a scrolling list or a popup can never clip it. Opens below and to the
454
+ * left of the button's right edge, and above it when there is no room below. */
455
+ function FloatingMenu({ menu, children }: { menu: ReturnType<typeof useMenu>; children: ReactNode }) {
456
+ const [pos, setPos] = useState<CSSProperties>({ visibility: "hidden" });
457
+ useLayoutEffect(() => {
458
+ const button = menu.ref.current?.getBoundingClientRect();
459
+ const box = menu.menuRef.current?.getBoundingClientRect();
460
+ if (!button || !box) return;
461
+ const below = button.bottom + 6 + box.height <= window.innerHeight - 8;
462
+ setPos({
463
+ top: below ? button.bottom + 6 : Math.max(8, button.top - 6 - box.height),
464
+ left: Math.max(8, Math.min(button.right - box.width, window.innerWidth - box.width - 8)),
465
+ });
466
+ }, [menu.ref, menu.menuRef]);
467
+ return createPortal(
468
+ <div ref={menu.menuRef} className="sd-menu sd-menu-floating" role="menu" style={pos} onClick={(e) => e.stopPropagation()}>
469
+ {children}
470
+ </div>,
471
+ document.body,
472
+ );
473
+ }
474
+
475
+ /** Saves the deck as a PDF (exactly as shown) or an editable PowerPoint, drawn by the server. `prepare` runs first
476
+ * (the viewer saves unsaved edits, so the file has them) and stops the download when it returns false. */
477
+ function useDeckDownload(artifact: Artifact, prepare?: () => Promise<boolean>) {
478
+ const [busy, setBusy] = useState<"pdf" | "pptx" | null>(null);
479
+ const [error, setError] = useState<string | null>(null);
480
+ const name = deckTitle(artifact).replace(/[^\w\- ]+/g, "").trim().slice(0, 80) || "Slide deck";
481
+ const save = async (ext: "pdf" | "pptx") => {
482
+ setBusy(ext);
483
+ setError(null);
484
+ try {
485
+ if (prepare && !(await prepare())) return;
486
+ const blob = await artifactsApi.slidesFile(artifact.id, ext);
487
+ const url = URL.createObjectURL(blob);
488
+ const a = document.createElement("a");
489
+ a.href = url;
490
+ a.download = `${name}.${ext}`;
491
+ document.body.appendChild(a);
492
+ a.click();
493
+ a.remove();
494
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
495
+ } catch (e) {
496
+ setError(errorMessage(e));
497
+ } finally {
498
+ setBusy(null);
499
+ }
500
+ };
501
+ return { busy, error, save };
502
+ }
503
+
504
+ function deckTitle(artifact: Artifact): string {
505
+ return artifact.title.replace(/^Slide deck: /, "") || (artifact.content.deck?.title as string | undefined) || "Slide deck";
506
+ }
507
+
508
+ /** "5d ago", "3h ago", "just now". */
509
+ function ago(iso: string | null): string {
510
+ if (!iso) return "";
511
+ const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
512
+ if (s < 60) return "just now";
513
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
514
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
515
+ if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
516
+ return formatDate(iso);
517
+ }
518
+
519
+ /** Download, in the popup's header: a menu of PDF or PowerPoint. */
520
+ function DownloadMenu({ artifact, prepare }: { artifact: Artifact; prepare?: () => Promise<boolean> }) {
521
+ const menu = useMenu();
522
+ const dl = useDeckDownload(artifact, prepare);
523
+ return (
524
+ <div className="sd-menu-wrap" ref={menu.ref}>
525
+ <button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} disabled={dl.busy !== null}
526
+ title={dl.busy ? "Preparing the file..." : "Download"} aria-label={dl.busy ? "Preparing the file" : "Download"}
527
+ onClick={() => menu.setOpen((o) => !o)}>
528
+ {dl.busy ? <span className="nbv-send-spinner" aria-hidden="true" /> : <DownloadIcon size={20} />}
529
+ </button>
530
+ {menu.open && (
531
+ <FloatingMenu menu={menu}>
532
+ <button type="button" role="menuitem" onClick={() => { menu.setOpen(false); void dl.save("pdf"); }}>
533
+ <PdfIcon size={20} /> Download PDF Document (.pdf)
534
+ </button>
535
+ <button type="button" role="menuitem" onClick={() => { menu.setOpen(false); void dl.save("pptx"); }}>
536
+ <PptxIcon size={20} /> Download PowerPoint (.pptx)
537
+ </button>
538
+ </FloatingMenu>
539
+ )}
540
+ {dl.error && <span className="sd-menu-error error-text small" role="alert">{dl.error}</span>}
541
+ </div>
542
+ );
543
+ }
544
+
545
+ /** Everything a deck can do, behind its three dots (the row's and the popup's): rename, download, present, speaker
546
+ * notes (in the popup) and delete (asks once more first). */
547
+ function DeckMenu({ artifact, onRename, onPresent, onDelete, notes, onNotes, prepare }: {
548
+ artifact: Artifact;
549
+ prepare?: () => Promise<boolean>;
550
+ onRename?: () => void;
551
+ onPresent: () => void;
552
+ onDelete: () => Promise<void>;
553
+ notes?: boolean;
554
+ onNotes?: () => void;
555
+ }) {
556
+ const menu = useMenu();
557
+ const dl = useDeckDownload(artifact, prepare);
558
+ const [confirming, setConfirming] = useState(false);
559
+ const pick = (fn: () => void) => () => {
560
+ menu.setOpen(false);
561
+ fn();
562
+ };
563
+ return (
564
+ <div className="sd-menu-wrap" ref={menu.ref} onClick={(e) => e.stopPropagation()}>
565
+ <button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} title="More" aria-label="More"
566
+ onClick={() => menu.setOpen((o) => !o)}>
567
+ {dl.busy ? <span className="nbv-send-spinner" aria-hidden="true" /> : <MoreIcon size={20} />}
568
+ </button>
569
+ {menu.open && (
570
+ <FloatingMenu menu={menu}>
571
+ {onRename && <button type="button" role="menuitem" onClick={pick(onRename)}><PencilIcon /> Rename</button>}
572
+ <button type="button" role="menuitem" onClick={pick(() => void dl.save("pdf"))}><PdfIcon size={20} /> Download PDF Document (.pdf)</button>
573
+ <button type="button" role="menuitem" onClick={pick(() => void dl.save("pptx"))}><PptxIcon size={20} /> Download PowerPoint (.pptx)</button>
574
+ <button type="button" role="menuitem" onClick={pick(onPresent)}><PlayIcon size={20} /> Start slideshow</button>
575
+ {onNotes && (
576
+ <button type="button" role="menuitemcheckbox" aria-checked={notes} onClick={pick(onNotes)}>
577
+ <SpeakerNotesIcon /> {notes ? "Hide speaker notes" : "Show speaker notes"}
578
+ </button>
579
+ )}
580
+ <button type="button" role="menuitem" className="danger" onClick={pick(() => setConfirming(true))}>
581
+ <TrashIcon size={20} /> Delete
582
+ </button>
583
+ </FloatingMenu>
584
+ )}
585
+ {confirming && <ConfirmDeleteModal heading="Delete slide deck?" name={deckTitle(artifact)} onCancel={() => setConfirming(false)}
586
+ onConfirm={onDelete} />}
587
+ {dl.error && <span className="sd-menu-error error-text small" role="alert">{dl.error}</span>}
588
+ </div>
589
+ );
590
+ }
591
+
592
+ function SvgIcon({ children }: { children: ReactNode }) {
593
+ return (
594
+ <svg className="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}
595
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
596
+ {children}
597
+ </svg>
598
+ );
599
+ }
600
+
601
+ const SpeakerNotesIcon = () => (
602
+ <SvgIcon>
603
+ <path d="M5 4h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-7l-4 3.5V17H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" />
604
+ <path d="M7.5 9h9M7.5 12.5h6" />
605
+ </SvgIcon>
606
+ );
607
+ const PencilIcon = () => (
608
+ <SvgIcon>
609
+ <path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4zM13.5 6.5l4 4" />
610
+ </SvgIcon>
611
+ );
612
+
613
+ const ArrowUpIcon = () => <SvgIcon><path d="M12 19V5M6 11l6-6 6 6" /></SvgIcon>;
614
+ const ArrowDownIcon = () => <SvgIcon><path d="M12 5v14M6 13l6 6 6-6" /></SvgIcon>;
615
+ const DuplicateIcon = () => (
616
+ <SvgIcon>
617
+ <rect x="8" y="8" width="12" height="12" rx="2" />
618
+ <path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
619
+ </SvgIcon>
620
+ );
621
+
622
+ /** The working copy edit mode changes: the slides as the editor has them, drawn, and what each slide's design has a
623
+ * place for. Typing changes the slides only (the frame already shows it); anything else is drawn again by the server. */
624
+ type Draft = { slides: EditSlide[]; pages: string[]; slots: string[][] };
625
+
626
+ function draftOf(artifact: Artifact): Draft {
627
+ const c = artifact.content;
628
+ return {
629
+ slides: JSON.parse(JSON.stringify(Array.isArray(c.deck?.slides) ? c.deck.slides : [])) as EditSlide[],
630
+ pages: Array.isArray(c.slides_html) ? (c.slides_html as string[]) : [],
631
+ slots: Array.isArray(c.slide_slots) ? (c.slide_slots as string[][]) : [],
632
+ };
633
+ }
634
+
635
+ /** One slide's controls in edit mode: its layout, the text boxes it can gain, and moving, duplicating or deleting it. */
636
+ function SlideTools({ k, count, slide, slots, fmt, busy, onChange, onMove, onDuplicate, onDelete }: {
637
+ k: number;
638
+ count: number;
639
+ slide: EditSlide;
640
+ slots: string[];
641
+ fmt: DeckFmt;
642
+ busy: boolean;
643
+ onChange: (next: EditSlide) => void;
644
+ onMove: (by: -1 | 1) => void;
645
+ onDuplicate: () => void;
646
+ onDelete: () => void;
647
+ }) {
648
+ return (
649
+ <div className="sd-tools" role="toolbar" aria-label={`Slide ${k + 1}`}>
650
+ <span className="sd-tools-no">Slide {k + 1}</span>
651
+ <label className="sd-tools-layout">
652
+ <span className="sr-only">Layout of slide {k + 1}</span>
653
+ <select className="input" value={slide.layout} disabled={busy} onChange={(e) => onChange(convert(slide, e.target.value, fmt))}>
654
+ {LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
655
+ </select>
656
+ </label>
657
+ {addActions(slide, slots, fmt).map((a) => (
658
+ <button key={a.id} type="button" className="sd-tool" disabled={busy} onClick={() => onChange(a.apply(slide))}>+ {a.label}</button>
659
+ ))}
660
+ <span className="sd-tools-gap" />
661
+ <button type="button" className="sd-icon-btn sd-tool-icon" title="Move up" aria-label={`Move slide ${k + 1} up`}
662
+ disabled={busy || k === 0} onClick={() => onMove(-1)}><ArrowUpIcon /></button>
663
+ <button type="button" className="sd-icon-btn sd-tool-icon" title="Move down" aria-label={`Move slide ${k + 1} down`}
664
+ disabled={busy || k === count - 1} onClick={() => onMove(1)}><ArrowDownIcon /></button>
665
+ <button type="button" className="sd-icon-btn sd-tool-icon" title="Duplicate slide" aria-label={`Duplicate slide ${k + 1}`}
666
+ disabled={busy || count >= MAX_SLIDES} onClick={onDuplicate}><DuplicateIcon /></button>
667
+ <button type="button" className="sd-icon-btn sd-tool-icon sd-tool-danger" title="Delete slide" aria-label={`Delete slide ${k + 1}`}
668
+ disabled={busy || count <= 1} onClick={onDelete}><TrashIcon size={20} /></button>
669
+ </div>
670
+ );
671
+ }
672
+
673
+ /** "+ Add slide" after a slide in edit mode: a new slide of the layout picked. */
674
+ function AddSlide({ after, busy, full, onAdd }: { after: number; busy: boolean; full: boolean; onAdd: (layout: string) => void }) {
675
+ return (
676
+ <div className="sd-add-slide">
677
+ <label>
678
+ <span className="sr-only">Add a slide after slide {after + 1}</span>
679
+ <select className="input" value="" disabled={busy || full} onChange={(e) => e.target.value && onAdd(e.target.value)}>
680
+ <option value="">{full ? `At most ${MAX_SLIDES} slides` : "+ Add slide here"}</option>
681
+ {LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
682
+ </select>
683
+ </label>
684
+ </div>
685
+ );
686
+ }
687
+
688
+ /** The deck in a popup: its actions along the top (edit, present, a larger preview, download, more) and every slide
689
+ * below, one after another. A click on a slide presents from it. The pencil opens edit mode: every text on the slides
690
+ * is a framed text box to type in (with a remove button where the slide can do without it), each slide has its
691
+ * controls (layout, text boxes to add, move, duplicate, delete), slides can be added between them, and the speaker
692
+ * notes are editable too. Save keeps it all; leaving edit mode or closing with unsaved changes asks first. Presenting
693
+ * or downloading saves first, so both show the edited deck. */
694
+ export function SlideDeckViewer({ artifact, onClose, onDelete, onSaved }: {
695
+ artifact: Artifact;
696
+ onClose: () => void;
697
+ onDelete: () => Promise<void>;
698
+ /** The deck as the server has it now (saved, or fetched afresh). */
699
+ onSaved: (artifact: Artifact) => void;
700
+ }) {
701
+ const stored = deckOf(artifact);
702
+ const [big, setBig] = useState(false);
703
+ const [notes, setNotes] = useState(false);
704
+ const [presenting, setPresenting] = useState<number | null>(null);
705
+ const [inView, setInView] = useState(0);
706
+ const [draft, setDraft] = useState<Draft | null>(null); // edit mode when set
707
+ const [dirty, setDirty] = useState(false);
708
+ const [busy, setBusy] = useState(false); // the server is drawing the slides again
709
+ const [saving, setSaving] = useState(false);
710
+ const [saveError, setSaveError] = useState<string | null>(null);
711
+ // Unsaved changes and the viewer is closing, or edit mode is being left: Save, Don't save or Cancel.
712
+ const [asking, setAsking] = useState<"close" | "done" | null>(null);
713
+ const [opening, setOpening] = useState(false); // fetching the deck afresh before edit mode
714
+ const [version, setVersion] = useState(0); // bumped to draw the slides again from the stored deck (changes dropped)
715
+ const previewSeq = useRef(0);
716
+ const stack = useRef<HTMLOListElement>(null);
717
+ const c = artifact.content;
718
+ const fmt: DeckFmt = c.format === "presenter" ? "presenter" : "detailed";
719
+ const theme = SLIDE_THEMES.find((t) => t.id === c.theme)?.name ?? "Night stellar";
720
+ const editing = draft !== null;
721
+ const pages = draft ? draft.pages : stored.pages;
722
+ const slides: DeckSlide[] = draft ? draft.slides : stored.slides;
723
+
724
+ const save = async (): Promise<boolean> => {
725
+ if (!draft || !dirty) return true;
726
+ const wrong = problem(draft.slides);
727
+ if (wrong) {
728
+ setSaveError(wrong);
729
+ return false;
730
+ }
731
+ setSaving(true);
732
+ setSaveError(null);
733
+ try {
734
+ const next = await artifactsApi.saveDeck(artifact.id, draft.slides);
735
+ onSaved(next);
736
+ setDraft(draftOf(next));
737
+ setDirty(false);
738
+ return true;
739
+ } catch (e) {
740
+ setSaveError(errorMessage(e));
741
+ return false;
742
+ } finally {
743
+ setSaving(false);
744
+ }
745
+ };
746
+ // A change to the deck's structure (a slide or a text box added, removed, moved, another layout): drawn by the server.
747
+ const restructure = async (next: EditSlide[]) => {
748
+ if (!draft) return;
749
+ setDraft({ ...draft, slides: next });
750
+ setDirty(true);
751
+ setBusy(true);
752
+ setSaveError(null);
753
+ const seq = ++previewSeq.current;
754
+ try {
755
+ const got = await artifactsApi.previewDeck(artifact.id, next);
756
+ if (seq === previewSeq.current) setDraft({ slides: got.slides, pages: got.slides_html, slots: got.slide_slots });
757
+ } catch (e) {
758
+ if (seq === previewSeq.current) setSaveError(errorMessage(e));
759
+ } finally {
760
+ if (seq === previewSeq.current) setBusy(false);
761
+ }
762
+ };
763
+ const change = (k: number, slide: EditSlide) => draft && void restructure(draft.slides.map((x, i) => (i === k ? slide : x)));
764
+ const move = (k: number, by: -1 | 1) => {
765
+ if (!draft) return;
766
+ const next = [...draft.slides];
767
+ [next[k], next[k + by]] = [next[k + by], next[k]];
768
+ void restructure(next);
769
+ };
770
+ const insert = (at: number, slide: EditSlide) => draft && void restructure([...draft.slides.slice(0, at), slide, ...draft.slides.slice(at)]);
771
+ const typed = (k: number, field: string, text: string) => {
772
+ setDraft((d) => (d ? { ...d, slides: d.slides.map((x, i) => (i === k ? setText(x, field, text) : x)) } : d));
773
+ setDirty(true);
774
+ };
775
+ const requestClose = () => (dirty ? setAsking("close") : onClose());
776
+ const leave = () => {
777
+ setDraft(null);
778
+ setDirty(false);
779
+ setBusy(false);
780
+ setSaveError(null);
781
+ previewSeq.current++;
782
+ setVersion((v) => v + 1);
783
+ };
784
+ const stopEditing = () => (dirty ? setAsking("done") : leave());
785
+ // Edit mode works on the deck as the server has it now (a deck loaded before an update has no editable text).
786
+ const startEditing = async () => {
787
+ setOpening(true);
788
+ setSaveError(null);
789
+ try {
790
+ const fresh = await artifactsApi.get(artifact.id);
791
+ onSaved(fresh);
792
+ if (!deckOf(fresh).pages.some((html) => html.includes("data-f="))) {
793
+ setSaveError("This deck can't be edited. Generate it again to edit its text.");
794
+ return;
795
+ }
796
+ setDraft(draftOf(fresh));
797
+ } catch (e) {
798
+ setSaveError(errorMessage(e));
799
+ } finally {
800
+ setOpening(false);
801
+ }
802
+ };
803
+ const present = async (k: number) => {
804
+ if (await save()) setPresenting(k);
805
+ };
806
+
807
+ // Leaving the page (closing the tab, reloading) with unsaved changes: the browser asks too.
808
+ useEffect(() => {
809
+ if (!dirty) return;
810
+ const warn = (e: BeforeUnloadEvent) => e.preventDefault();
811
+ window.addEventListener("beforeunload", warn);
812
+ return () => window.removeEventListener("beforeunload", warn);
813
+ }, [dirty]);
814
+
815
+ // The slide most in view: Present starts there.
816
+ useEffect(() => {
817
+ const root = stack.current;
818
+ if (!root || typeof IntersectionObserver === "undefined") return;
819
+ const seen = new Map<number, number>();
820
+ const io = new IntersectionObserver((entries) => {
821
+ for (const e of entries) seen.set(Number((e.target as HTMLElement).dataset.i), e.intersectionRatio);
822
+ let best = 0;
823
+ let ratio = -1;
824
+ seen.forEach((r, i) => {
825
+ if (r > ratio) [best, ratio] = [i, r];
826
+ });
827
+ setInView(best);
828
+ }, { root, threshold: [0, 0.25, 0.5, 0.75, 1] });
829
+ root.querySelectorAll("[data-i]").forEach((el) => io.observe(el));
830
+ return () => io.disconnect();
831
+ }, [pages.length, notes, editing]);
832
+
833
+ const actions = (
834
+ <div className="sd-head-actions" role="toolbar" aria-label="Slide deck">
835
+ {editing ? (
836
+ <>
837
+ <button type="button" className="btn btn-primary btn-small sd-save" disabled={saving || busy || !dirty} onClick={() => void save()}>
838
+ {saving ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Saving...</>) : "Save"}
839
+ </button>
840
+ <button type="button" className="btn btn-small sd-save" disabled={saving} onClick={stopEditing}>Done</button>
841
+ </>
842
+ ) : (
843
+ <button type="button" className="sd-icon-btn" title="Edit slides" aria-label="Edit slides" disabled={!pages.length || opening}
844
+ onClick={() => void startEditing()}>
845
+ {opening ? <span className="nbv-send-spinner" aria-hidden="true" /> : <PencilIcon />}
846
+ </button>
847
+ )}
848
+ <button type="button" className="sd-icon-btn" title="Start slideshow" aria-label="Start slideshow"
849
+ disabled={!pages.length || saving || busy} onClick={() => void present(inView)}>
850
+ <PlayIcon size={20} />
851
+ </button>
852
+ <button type="button" className="sd-icon-btn" title={big ? "Smaller preview" : "Larger preview"} aria-pressed={big}
853
+ aria-label={big ? "Smaller preview" : "Larger preview"} onClick={() => setBig((b) => !b)}>
854
+ {big ? <CollapseIcon size={20} /> : <ExpandIcon size={20} />}
855
+ </button>
856
+ {pages.length > 0 && <DownloadMenu artifact={artifact} prepare={save} />}
857
+ <DeckMenu artifact={artifact} onPresent={() => void present(inView)} onDelete={onDelete} prepare={save}
858
+ notes={notes} onNotes={() => setNotes((n) => !n)} />
859
+ <button type="button" className="sd-icon-btn" onClick={requestClose} title="Close" aria-label="Close">
860
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
861
+ <path d="M6 6l12 12M18 6L6 18" />
862
+ </svg>
863
+ </button>
864
+ </div>
865
+ );
866
+
867
+ return (
868
+ <Modal title={deckTitle(artifact)} onClose={requestClose} wide actions={actions}>
869
+ <div className={`sd-view${big ? " big" : ""}`}>
870
+ <p className="muted small sd-meta">
871
+ {pages.length} slides · {fmt === "presenter" ? "Presenter" : "Detailed"} · {theme}
872
+ {c.source ? ` · from ${sourceSummary(c.source)}` : ""}
873
+ <span className="sd-edit-hint">
874
+ {editing ? ` · Editing: click a text box to type${busy ? " · Updating..." : dirty ? " · Unsaved changes" : ""}` : ""}
875
+ </span>
876
+ </p>
877
+ {saveError && !asking && <p className="error-text small sd-edit-error" role="alert">{saveError}</p>}
878
+ {pages.length === 0 ? (
879
+ <p className="error-text">This deck has no slides to show. Try generating it again.</p>
880
+ ) : (
881
+ <ol className={`sd-stack${busy ? " sd-busy" : ""}`} ref={stack} aria-label="Slides" aria-busy={busy}>
882
+ {pages.map((html, k) => {
883
+ const s = slides[k];
884
+ const sources = [...new Map((s?.sources ?? []).map((x) => [x.title || x.path, x])).values()];
885
+ const e = draft?.slides[k];
886
+ return (
887
+ <li key={k} data-i={k}>
888
+ {draft && e && (
889
+ <SlideTools k={k} count={draft.slides.length} slide={e} slots={draft.slots[k] ?? []} fmt={fmt} busy={busy || saving}
890
+ onChange={(next) => change(k, next)} onMove={(by) => move(k, by)}
891
+ onDuplicate={() => insert(k + 1, { ...JSON.parse(JSON.stringify(e)), variant: "" })}
892
+ onDelete={() => void restructure(draft.slides.filter((_, i) => i !== k))} />
893
+ )}
894
+ <div className={`sd-slide${editing ? " sd-slide-editing" : ""}`} role={editing ? undefined : "button"}
895
+ tabIndex={editing ? undefined : 0} aria-label={editing ? undefined : `Present from slide ${k + 1}: ${s?.heading ?? ""}`}
896
+ onClick={editing ? undefined : () => void present(k)}
897
+ onKeyDown={editing ? undefined : (ev) => (ev.key === "Enter" || ev.key === " ") && (ev.preventDefault(), void present(k))}>
898
+ <SlideFrame key={version} html={html} title={`Slide ${k + 1}: ${s?.heading ?? ""}`} editing={editing && !busy}
899
+ onEdit={(field, text) => typed(k, field, text)}
900
+ removable={e ? removable(e) : []} onRemove={(field) => e && change(k, removeField(e, field))} />
901
+ </div>
902
+ {notes && (
903
+ <div className="sd-notes">
904
+ {e ? (
905
+ <>
906
+ <label className="vw-label" htmlFor={`sd-notes-${artifact.id}-${k}`}>Slide {k + 1} notes</label>
907
+ <textarea id={`sd-notes-${artifact.id}-${k}`} className="textarea sd-notes-edit" rows={3} maxLength={1500}
908
+ placeholder="No notes for this slide yet." value={e.notes}
909
+ onChange={(ev) => typed(k, "notes", ev.target.value)} />
910
+ </>
911
+ ) : (
912
+ <>
913
+ <span className="vw-label">Slide {k + 1}</span>
914
+ <p>{s?.notes || <span className="muted">No notes for this slide.</span>}</p>
915
+ </>
916
+ )}
917
+ {sources.length > 0 && (
918
+ <p className="muted small">
919
+ From {sources.map((x) => `${x.title || x.path} (lines ${x.line_start} to ${x.line_end})`).join("; ")}
920
+ </p>
921
+ )}
922
+ </div>
923
+ )}
924
+ {draft && (
925
+ <AddSlide after={k} busy={busy || saving} full={draft.slides.length >= MAX_SLIDES}
926
+ onAdd={(layout) => insert(k + 1, newSlide(layout, fmt))} />
927
+ )}
928
+ </li>
929
+ );
930
+ })}
931
+ </ol>
932
+ )}
933
+ </div>
934
+ {presenting !== null && <SlidePresenter artifact={artifact} start={presenting} onClose={() => setPresenting(null)} />}
935
+ {asking && (
936
+ <Modal title="Save changes?" onClose={() => !saving && setAsking(null)}>
937
+ <div className="stack sd-confirm">
938
+ <p>You edited this slide deck. Do you want to save your changes{asking === "close" ? " before closing" : ""}?</p>
939
+ {saveError && <p className="error-text" role="alert">{saveError}</p>}
940
+ <div className="row sd-confirm-actions">
941
+ <button type="button" className="btn" onClick={() => setAsking(null)} disabled={saving}>Cancel</button>
942
+ <button type="button" className="btn" disabled={saving} onClick={() => {
943
+ if (asking === "close") return onClose();
944
+ leave();
945
+ setAsking(null);
946
+ }}>Don't save</button>
947
+ <button type="button" className="btn btn-primary" disabled={saving || busy} autoFocus onClick={async () => {
948
+ if (!(await save())) return;
949
+ if (asking === "close") return onClose();
950
+ setAsking(null);
951
+ leave();
952
+ }}>
953
+ {saving ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Saving...</>) : "Save"}
954
+ </button>
955
+ </div>
956
+ </div>
957
+ </Modal>
958
+ )}
959
+ </Modal>
960
+ );
961
+ }
962
+
963
+ /** A deck in the notebook's list, as one quiet row: its icon, its title, "2 sources · 5d ago", and its menu. A click
964
+ * opens it. While it is being made the row says so and shows the progress. */
965
+ export function SlideDeckRow({ artifact: initial, onRemoved }: { artifact: Artifact; onRemoved: (id: string) => void }) {
966
+ const [artifact, setArtifact] = useState(initial);
967
+ const [open, setOpen] = useState(false);
968
+ const [presenting, setPresenting] = useState(false);
969
+ const [renaming, setRenaming] = useState(false);
970
+ const [name, setName] = useState("");
971
+ const [error, setError] = useState<string | null>(null);
972
+ const job = useJob(artifact.job, () => {
973
+ artifactsApi.get(artifact.id).then(setArtifact);
974
+ });
975
+ const running = job && (job.status === "queued" || job.status === "running");
976
+ const ready = artifact.status === "ready";
977
+ const failed = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error ?? "Could not make this deck." : null;
978
+ const title = deckTitle(artifact);
979
+ const count = (artifact.content.source?.count as number | undefined) ?? 0;
980
+ const meta = running || !ready
981
+ ? (failed ? "Slide deck · could not be made" : `Generating slide deck... based on ${count || "your"} source${count === 1 ? "" : "s"}`)
982
+ : `${count} source${count === 1 ? "" : "s"} · ${ago(artifact.created_at)}`;
983
+
984
+ const remove = async () => {
985
+ await artifactsApi.remove(artifact.id);
986
+ setOpen(false);
987
+ onRemoved(artifact.id);
988
+ };
989
+ const rename = async () => {
990
+ const next = name.trim();
991
+ setRenaming(false);
992
+ if (!next || next === title) return;
993
+ setError(null);
994
+ try {
995
+ const deck = artifact.content.deck ? { ...artifact.content.deck, title: next } : undefined;
996
+ setArtifact(await artifactsApi.update(artifact.id, { title: next, ...(deck ? { content: { deck } } : {}) }));
997
+ } catch (e) {
998
+ setError(errorMessage(e));
999
+ }
1000
+ };
1001
+
1002
+ return (
1003
+ <div className={`sd-item${ready ? " is-ready" : ""}${running ? " is-running" : ""}`}>
1004
+ <div className="sd-item-main" role={ready && !renaming ? "button" : undefined} tabIndex={ready && !renaming ? 0 : undefined}
1005
+ aria-label={ready && !renaming ? `Open slide deck: ${title}` : undefined}
1006
+ onClick={() => ready && !renaming && setOpen(true)}
1007
+ onKeyDown={(e) => ready && !renaming && (e.key === "Enter" || e.key === " ") && (e.preventDefault(), setOpen(true))}>
1008
+ <span className="sd-item-icon" aria-hidden="true">
1009
+ {running ? <span className="nbv-send-spinner" /> : <SlidesIcon size={22} />}
1010
+ </span>
1011
+ <span className="sd-item-text">
1012
+ {renaming ? (
1013
+ <input className="input sd-rename" autoFocus value={name} maxLength={140} aria-label="Slide deck name"
1014
+ onChange={(e) => setName(e.target.value)} onBlur={rename} onClick={(e) => e.stopPropagation()}
1015
+ onKeyDown={(e) => {
1016
+ e.stopPropagation();
1017
+ if (e.key === "Enter") void rename();
1018
+ if (e.key === "Escape") setRenaming(false);
1019
+ }} />
1020
+ ) : (
1021
+ <strong title={title}>{running ? "Generating slide deck..." : title}</strong>
1022
+ )}
1023
+ <span className="sd-item-meta">{meta}</span>
1024
+ </span>
1025
+ </div>
1026
+ {ready && (
1027
+ <DeckMenu artifact={artifact} onRename={() => { setName(title); setRenaming(true); }} onPresent={() => setPresenting(true)}
1028
+ onDelete={remove} />
1029
+ )}
1030
+ {running && job && (
1031
+ <div className="sd-item-progress">
1032
+ <JobProgress job={job} compact />
1033
+ </div>
1034
+ )}
1035
+ {failed && (
1036
+ <div className="sd-item-progress sd-item-failed">
1037
+ <p className="error-text small">{failed}</p>
1038
+ <button type="button" className="link-btn" onClick={async () => {
1039
+ setError(null);
1040
+ try {
1041
+ setArtifact(await artifactsApi.retry(artifact.id));
1042
+ } catch (e) {
1043
+ setError(errorMessage(e));
1044
+ }
1045
+ }}>Retry</button>
1046
+ <button type="button" className="link-btn" onClick={() => void remove()}>Remove</button>
1047
+ </div>
1048
+ )}
1049
+ {error && <p className="error-text small sd-item-progress">{error}</p>}
1050
+ {open && <SlideDeckViewer artifact={artifact} onClose={() => setOpen(false)} onDelete={remove} onSaved={setArtifact} />}
1051
+ {presenting && <SlidePresenter artifact={artifact} onClose={() => setPresenting(false)} />}
1052
+ </div>
1053
+ );
1054
+ }
frontend/src/components/icons/Icons.tsx CHANGED
@@ -239,6 +239,62 @@ export const InfographicIcon = (p: P) => (
239
  </Icon>
240
  );
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  /** A question mark in a circle: the quiz. */
243
  export const QuizIcon = (p: P) => (
244
  <Icon {...p}>
 
239
  </Icon>
240
  );
241
 
242
+ /** A slide on a stand, a second one behind it: a slide deck. */
243
+ export const SlidesIcon = (p: P) => (
244
+ <Icon {...p}>
245
+ <rect x="3" y="4" width="15" height="10.5" rx="1.8" />
246
+ <path d="M6.5 1.9h12.7A1.8 1.8 0 0 1 21 3.7v8.6" />
247
+ <path d="M10.5 14.5V19M7 21h7M6.5 8h5M6.5 11h8" />
248
+ </Icon>
249
+ );
250
+
251
+ /** A play triangle: present a deck. */
252
+ export const PlayIcon = (p: P) => (
253
+ <Icon {...p}>
254
+ <path d="M7.5 5.2v13.6a.8.8 0 0 0 1.2.7l10.6-6.8a.8.8 0 0 0 0-1.4L8.7 4.5a.8.8 0 0 0-1.2.7z" />
255
+ </Icon>
256
+ );
257
+
258
+ /** Four corners pointing out: show larger. */
259
+ export const ExpandIcon = (p: P) => (
260
+ <Icon {...p}>
261
+ <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
262
+ </Icon>
263
+ );
264
+
265
+ /** Four corners pointing in: back to the normal size. */
266
+ export const CollapseIcon = (p: P) => (
267
+ <Icon {...p}>
268
+ <path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
269
+ </Icon>
270
+ );
271
+
272
+ /** Three dots, stacked: more actions. */
273
+ export const MoreIcon = (p: P) => (
274
+ <Icon {...p}>
275
+ <circle cx="12" cy="5.5" r="1.1" fill="currentColor" />
276
+ <circle cx="12" cy="12" r="1.1" fill="currentColor" />
277
+ <circle cx="12" cy="18.5" r="1.1" fill="currentColor" />
278
+ </Icon>
279
+ );
280
+
281
+ /** A framed PDF mark: a PDF file. */
282
+ export const PdfIcon = (p: P) => (
283
+ <Icon {...p}>
284
+ <rect x="3" y="4" width="18" height="16" rx="2.5" />
285
+ <text x="12" y="15" textAnchor="middle" fontSize="6.4" fontWeight="700" fill="currentColor" stroke="none"
286
+ fontFamily="Inter, sans-serif">PDF</text>
287
+ </Icon>
288
+ );
289
+
290
+ /** A framed P: a PowerPoint file. */
291
+ export const PptxIcon = (p: P) => (
292
+ <Icon {...p}>
293
+ <rect x="3" y="4" width="18" height="16" rx="2.5" />
294
+ <path d="M10 16V8h2.8a2.3 2.3 0 0 1 0 4.6H10" />
295
+ </Icon>
296
+ );
297
+
298
  /** A question mark in a circle: the quiz. */
299
  export const QuizIcon = (p: P) => (
300
  <Icon {...p}>
frontend/src/components/launchpad/Attachment.tsx CHANGED
@@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
4
  import { ApiError } from "../../api/client";
5
  import { artifactsApi, launchpadApi, videoEditApi, videosApi } from "../../api/endpoints";
6
  import type { PostableArtifact } from "../../api/types";
7
- import { ArrowRightIcon, FilmIcon, SparkleIcon, UploadIcon } from "../icons/Icons";
8
  import { errorMessage, formatDate, Loading, Modal } from "../ui";
9
  import { finished, jobPercent } from "../video/jobState";
10
 
@@ -18,9 +18,16 @@ export function mediaLabel(a: PostableArtifact): string {
18
  if (a.type === "upload") {
19
  return a.media === "video" ? `Your video${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""}` : "Your image";
20
  }
 
21
  if (a.media === "video" && a.rendering) return "Rendering: wait for it to finish";
22
  if (a.media === "video" && a.needs_render) return "Not rendered yet: render it first";
23
  if (a.media === "video") return a.duration_s ? `Video ${fmtDuration(a.duration_s)}` : "Video";
 
 
 
 
 
 
24
  if (a.media === "image") return a.media_count === 1 ? "Image" : `${a.media_count} images`;
25
  return "Text";
26
  }
@@ -29,7 +36,8 @@ function Thumb({ a }: { a: PostableArtifact }) {
29
  if (a.thumb_url) return <img className="lp-thumb" src={a.thumb_url} alt="" />;
30
  return (
31
  <span className="lp-thumb lp-thumb-icon" aria-hidden="true">
32
- {a.media === "video" ? <FilmIcon size={20} /> : <SparkleIcon size={20} />}
 
33
  </span>
34
  );
35
  }
@@ -55,6 +63,32 @@ export function MediaPreviewModal({ a, onClose }: { a: PostableArtifact; onClose
55
  );
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  /** The attached item: thumbnail, title, what it carries, and Change / Remove. An uploaded file's thumbnail opens its
59
  * preview. */
60
  export function AttachmentChip({ a, onChange, onRemove }: {
@@ -307,16 +341,29 @@ function deleteWording(a: PostableArtifact): { title: string; body: string } {
307
  return { title: "Delete this item?", body: "It's deleted for good. Posts on your calendar that carry it are removed too." };
308
  }
309
 
310
- type Kind = "all" | "kits" | "video" | "uploads";
311
  const KINDS: { id: Kind; label: string }[] = [
312
  { id: "all", label: "All" },
313
  { id: "kits", label: "Launch kits" },
314
  { id: "video", label: "Videos" },
 
 
315
  { id: "uploads", label: "Uploads" },
316
  ];
317
  /** Which rows a tab shows: Videos is only the ones the video service made (uploaded videos are under Uploads). */
318
  const matches = (a: PostableArtifact, kind: Kind) =>
319
- kind === "all" || (kind === "kits" ? a.type === "launch_kit" : kind === "uploads" ? a.type === "upload" : a.editable_video);
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  /** What can be uploaded to post, and LinkedIn's size limits for it (checked again by the backend). */
322
  const UPLOAD_ACCEPT = "image/png,image/jpeg,image/gif,video/mp4";
@@ -366,11 +413,20 @@ export function AttachPicker({ onPick, onCreateKit, onClose }: {
366
  * searchable; with a link to create a new launch kit when onCreateKit is given. onPickKit, when given, takes the
367
  * Launch Kits instead of onPick (the Schedule a launch page opens the kit, to schedule its posts from there). A video
368
  * with no MP4 yet (or one rendering) opens the render pop-up instead. fill: the rows take the screen's height. */
369
- export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }: {
370
  onPick: (a: PostableArtifact) => void;
371
  onPickKit?: (a: PostableArtifact) => void;
372
  onCreateKit?: () => void;
373
  fill?: boolean;
 
 
 
 
 
 
 
 
 
374
  }) {
375
  const [items, setItems] = useState<PostableArtifact[] | null>(null);
376
  const [kind, setKind] = useState<Kind>("all");
@@ -382,6 +438,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
382
  const [dragging, setDragging] = useState(false); // a file is held over the drop area
383
  const [renaming, setRenaming] = useState<{ id: string; title: string; busy?: boolean } | null>(null);
384
  const [previewing, setPreviewing] = useState<PostableArtifact | null>(null); // the upload being looked at
 
385
  const [deleting, setDeleting] = useState<PostableArtifact | null>(null); // the item in the delete pop-up
386
  const [deleteBusy, setDeleteBusy] = useState(false);
387
  const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -441,7 +498,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
441
  const load = useCallback(() => launchpadApi.postable(q).then((list) => {
442
  setItems(list);
443
  return list;
444
- }), [q]);
445
 
446
  useEffect(() => {
447
  const t = setTimeout(() => {
@@ -540,12 +597,18 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
540
  )}
541
  {items && shown.length === 0 && (
542
  <p className="muted lp-pick-empty">{kind === "kits" ? "No launch kits yet: create a new one."
543
- : kind === "uploads" ? "Nothing uploaded yet." : "Nothing to attach here yet."}</p>
 
 
544
  )}
545
  <ul className={`lk-rows lp-pick-rows${fill ? " lp-pick-rows-fill" : ""}`}>
546
  {shown.map((a) => {
547
  const kit = a.type === "launch_kit";
548
- const go = () => (a.needs_render || a.rendering ? setRendering(a) : kit && onPickKit ? onPickKit(a) : onPick(a));
 
 
 
 
549
  if (renaming?.id === a.id) {
550
  return (
551
  <li key={a.id}>
@@ -570,7 +633,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
570
  }
571
  return (
572
  <li key={a.id} className="lp-pick-item">
573
- <button type="button" className="lk-row lp-pick-row" onClick={go}
574
  title={a.rendering ? "Rendering: it can be scheduled once the MP4 is made"
575
  : a.needs_render ? "Render it, then post it" : undefined}>
576
  <Thumb a={a} />
@@ -578,22 +641,33 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
578
  <strong>{a.title}</strong>
579
  <span className="muted small">
580
  {kit ? "LinkedIn post and hooks ready to schedule" : mediaLabel(a)}
581
- {a.created_at ? ` · ${formatDate(a.created_at)}` : ""}
 
582
  </span>
583
  </span>
584
- <span className={`lp-tag${a.media === "video" ? " video" : kit ? " kit" : ""}`}>
585
- {a.type === "upload" ? "Upload" : a.media === "video" ? "Video" : kit ? "Launch Kit" : a.type_label}
 
586
  </span>
587
  <span className="lp-pick-go">
588
- {a.rendering ? (
 
 
589
  <><span className="vw-spin small" aria-hidden="true" /> Rendering</>
590
  ) : (
591
- <>{a.needs_render ? "Render it" : kit && onPickKit ? "Open kit" : "Schedule"} <ArrowRightIcon size={14} /></>
 
592
  )}
593
  </span>
594
  </button>
595
  <RowMenu label={a.title} items={[
596
  ...(a.view_url ? [{ label: "Preview", onSelect: () => setPreviewing(a) }] : []),
 
 
 
 
 
 
597
  ...(a.type === "upload" ? [{ label: "Rename", onSelect: () => setRenaming({ id: a.id, title: a.title }) }] : []),
598
  ...(kit ? [{ label: "Open kit", onSelect: () => (onPickKit ? onPickKit(a) : navigate(`/app/launchpad/kits/${a.id}`)) }]
599
  : a.editable_video ? [{ label: "Open", onSelect: () => navigate(`/app/videos/${a.id}`) }] : []),
@@ -608,6 +682,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
608
  </ul>
609
  {rendering && <RenderVideoModal video={rendering} onClose={() => setRendering(null)} onRendered={rendered} />}
610
  {previewing && <MediaPreviewModal a={previewing} onClose={() => setPreviewing(null)} />}
 
611
  {deleting && (
612
  <Modal title={deleteWording(deleting).title} onClose={() => !deleteBusy && setDeleting(null)}>
613
  <div className="stack">
 
4
  import { ApiError } from "../../api/client";
5
  import { artifactsApi, launchpadApi, videoEditApi, videosApi } from "../../api/endpoints";
6
  import type { PostableArtifact } from "../../api/types";
7
+ import { ArrowRightIcon, DownloadIcon, FilmIcon, HeadphonesIcon, SlidesIcon, SparkleIcon, UploadIcon } from "../icons/Icons";
8
  import { errorMessage, formatDate, Loading, Modal } from "../ui";
9
  import { finished, jobPercent } from "../video/jobState";
10
 
 
18
  if (a.type === "upload") {
19
  return a.media === "video" ? `Your video${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""}` : "Your image";
20
  }
21
+ if (a.media === "audio") return `Audio${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""} · download only`;
22
  if (a.media === "video" && a.rendering) return "Rendering: wait for it to finish";
23
  if (a.media === "video" && a.needs_render) return "Not rendered yet: render it first";
24
  if (a.media === "video") return a.duration_s ? `Video ${fmtDuration(a.duration_s)}` : "Video";
25
+ if (a.type === "slide_deck") {
26
+ // X takes 4 images and LinkedIn 20 (backend app/services/social/media.py MAX_IMAGES): longer decks are cut there.
27
+ const n = a.media_count;
28
+ const cut = n > 20 ? " · X posts the first 4, LinkedIn the first 20" : n > 4 ? " · X posts the first 4" : "";
29
+ return `${n} slide${n === 1 ? "" : "s"} as images${cut}`;
30
+ }
31
  if (a.media === "image") return a.media_count === 1 ? "Image" : `${a.media_count} images`;
32
  return "Text";
33
  }
 
36
  if (a.thumb_url) return <img className="lp-thumb" src={a.thumb_url} alt="" />;
37
  return (
38
  <span className="lp-thumb lp-thumb-icon" aria-hidden="true">
39
+ {a.media === "video" ? <FilmIcon size={20} /> : a.media === "audio" ? <HeadphonesIcon size={20} />
40
+ : a.type === "slide_deck" ? <SlidesIcon size={20} /> : <SparkleIcon size={20} />}
41
  </span>
42
  );
43
  }
 
63
  );
64
  }
65
 
66
+ /** An audio overview someone tried to schedule: X and LinkedIn take no audio files, so it is offered to download
67
+ * instead. Used by the Launchpad's list and the Library. */
68
+ export function AudioOnlyModal({ title, downloadUrl, onClose }: { title: string; downloadUrl: string | null | undefined; onClose: () => void }) {
69
+ return (
70
+ <Modal title="Audio can't be posted" onClose={onClose}>
71
+ <div className="stack lp-audio-only">
72
+ <span className="lp-audio-only-icon" aria-hidden="true"><HeadphonesIcon size={28} /></span>
73
+ <p>
74
+ LinkedIn does not support posting audio files, so <strong>{title}</strong> can't be scheduled. Download it now
75
+ to use it elsewhere, like a podcast app, your newsletter or a website.
76
+ </p>
77
+ <div className="row lp-audio-only-actions">
78
+ <button type="button" className="btn" onClick={onClose}>Close</button>
79
+ {downloadUrl ? (
80
+ <a className="btn btn-primary" href={downloadUrl} download onClick={onClose} autoFocus>
81
+ <DownloadIcon size={18} /> Download
82
+ </a>
83
+ ) : (
84
+ <span className="muted small">The file isn't available right now. Try again later.</span>
85
+ )}
86
+ </div>
87
+ </div>
88
+ </Modal>
89
+ );
90
+ }
91
+
92
  /** The attached item: thumbnail, title, what it carries, and Change / Remove. An uploaded file's thumbnail opens its
93
  * preview. */
94
  export function AttachmentChip({ a, onChange, onRemove }: {
 
341
  return { title: "Delete this item?", body: "It's deleted for good. Posts on your calendar that carry it are removed too." };
342
  }
343
 
344
+ type Kind = "all" | "kits" | "video" | "decks" | "audio" | "uploads";
345
  const KINDS: { id: Kind; label: string }[] = [
346
  { id: "all", label: "All" },
347
  { id: "kits", label: "Launch kits" },
348
  { id: "video", label: "Videos" },
349
+ { id: "decks", label: "Slide decks" },
350
+ { id: "audio", label: "Audio" },
351
  { id: "uploads", label: "Uploads" },
352
  ];
353
  /** Which rows a tab shows: Videos is only the ones the video service made (uploaded videos are under Uploads). */
354
  const matches = (a: PostableArtifact, kind: Kind) =>
355
+ kind === "all" || (kind === "kits" ? a.type === "launch_kit" : kind === "uploads" ? a.type === "upload"
356
+ : kind === "decks" ? a.type === "slide_deck" : kind === "audio" ? a.type === "audio_overview" : a.editable_video);
357
+
358
+ /** Saves a file from its (presigned, attachment) link. */
359
+ function download(url: string) {
360
+ const link = document.createElement("a");
361
+ link.href = url;
362
+ link.download = "";
363
+ document.body.appendChild(link);
364
+ link.click();
365
+ link.remove();
366
+ }
367
 
368
  /** What can be uploaded to post, and LinkedIn's size limits for it (checked again by the backend). */
369
  const UPLOAD_ACCEPT = "image/png,image/jpeg,image/gif,video/mp4";
 
413
  * searchable; with a link to create a new launch kit when onCreateKit is given. onPickKit, when given, takes the
414
  * Launch Kits instead of onPick (the Schedule a launch page opens the kit, to schedule its posts from there). A video
415
  * with no MP4 yet (or one rendering) opens the render pop-up instead. fill: the rows take the screen's height. */
416
+ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false, onReschedule, onDuplicate, refreshKey = 0, openingId }: {
417
  onPick: (a: PostableArtifact) => void;
418
  onPickKit?: (a: PostableArtifact) => void;
419
  onCreateKit?: () => void;
420
  fill?: boolean;
421
+ /** With these (the Schedule a launch page), an item already scheduled says Reschedule and opens that post
422
+ * (`itemId`), and its menu can schedule it again (a copy of that post). Without them (the composer's picker), every
423
+ * item is simply picked. */
424
+ onReschedule?: (a: PostableArtifact, itemId: string) => void;
425
+ onDuplicate?: (a: PostableArtifact, itemId: string) => void;
426
+ /** Changed by the page to load the list again (a post was rescheduled, moved or deleted). */
427
+ refreshKey?: number;
428
+ /** The item whose scheduled post is being opened: its row shows a spinner and takes no clicks meanwhile. */
429
+ openingId?: string | null;
430
  }) {
431
  const [items, setItems] = useState<PostableArtifact[] | null>(null);
432
  const [kind, setKind] = useState<Kind>("all");
 
438
  const [dragging, setDragging] = useState(false); // a file is held over the drop area
439
  const [renaming, setRenaming] = useState<{ id: string; title: string; busy?: boolean } | null>(null);
440
  const [previewing, setPreviewing] = useState<PostableArtifact | null>(null); // the upload being looked at
441
+ const [audio, setAudio] = useState<PostableArtifact | null>(null); // an audio overview: offered to download, not posted
442
  const [deleting, setDeleting] = useState<PostableArtifact | null>(null); // the item in the delete pop-up
443
  const [deleteBusy, setDeleteBusy] = useState(false);
444
  const [deleteError, setDeleteError] = useState<string | null>(null);
 
498
  const load = useCallback(() => launchpadApi.postable(q).then((list) => {
499
  setItems(list);
500
  return list;
501
+ }), [q, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
502
 
503
  useEffect(() => {
504
  const t = setTimeout(() => {
 
597
  )}
598
  {items && shown.length === 0 && (
599
  <p className="muted lp-pick-empty">{kind === "kits" ? "No launch kits yet: create a new one."
600
+ : kind === "uploads" ? "Nothing uploaded yet." : kind === "decks" ? "No slide decks yet: make one from a notebook."
601
+ : kind === "audio" ? "No audio overviews yet: make one from a notebook."
602
+ : "Nothing to attach here yet."}</p>
603
  )}
604
  <ul className={`lk-rows lp-pick-rows${fill ? " lp-pick-rows-fill" : ""}`}>
605
  {shown.map((a) => {
606
  const kit = a.type === "launch_kit";
607
+ // Already on the calendar (and this list offers it): open that post rather than start another one.
608
+ const next = onReschedule && !kit && a.media !== "audio" ? a.scheduled?.[0] : undefined;
609
+ const opening = openingId === a.id;
610
+ const go = () => opening ? undefined : (a.media === "audio" ? setAudio(a) : a.needs_render || a.rendering ? setRendering(a)
611
+ : next ? onReschedule!(a, next.id) : kit && onPickKit ? onPickKit(a) : onPick(a));
612
  if (renaming?.id === a.id) {
613
  return (
614
  <li key={a.id}>
 
633
  }
634
  return (
635
  <li key={a.id} className="lp-pick-item">
636
+ <button type="button" className="lk-row lp-pick-row" onClick={go} aria-busy={opening}
637
  title={a.rendering ? "Rendering: it can be scheduled once the MP4 is made"
638
  : a.needs_render ? "Render it, then post it" : undefined}>
639
  <Thumb a={a} />
 
641
  <strong>{a.title}</strong>
642
  <span className="muted small">
643
  {kit ? "LinkedIn post and hooks ready to schedule" : mediaLabel(a)}
644
+ {next ? ` · Scheduled ${formatDate(next.scheduled_at, true)}${a.scheduled!.length > 1
645
+ ? ` +${a.scheduled!.length - 1} more` : ""}` : a.created_at ? ` · ${formatDate(a.created_at)}` : ""}
646
  </span>
647
  </span>
648
+ <span className={`lp-tag${a.media === "video" ? " video" : kit ? " kit" : a.type === "slide_deck" ? " deck" : ""}`}>
649
+ {a.type === "upload" ? "Upload" : a.media === "video" ? "Video" : a.media === "audio" ? "Audio" : kit ? "Launch Kit"
650
+ : a.type_label}
651
  </span>
652
  <span className="lp-pick-go">
653
+ {opening ? (
654
+ <><span className="vw-spin small" aria-hidden="true" /> Opening...</>
655
+ ) : a.rendering ? (
656
  <><span className="vw-spin small" aria-hidden="true" /> Rendering</>
657
  ) : (
658
+ <>{a.needs_render ? "Render it" : kit && onPickKit ? "Open kit" : next ? "Reschedule" : "Schedule"}
659
+ {" "}<ArrowRightIcon size={14} /></>
660
  )}
661
  </span>
662
  </button>
663
  <RowMenu label={a.title} items={[
664
  ...(a.view_url ? [{ label: "Preview", onSelect: () => setPreviewing(a) }] : []),
665
+ ...(a.download_url ? [{ label: "Download", onSelect: () => download(a.download_url!) }] : []),
666
+ ...(next && onDuplicate ? [{ label: "Duplicate schedule", onSelect: () => onDuplicate(a, next.id) }] : []),
667
+ // Several posts of it coming up: each can be opened.
668
+ ...(next && a.scheduled!.length > 1 ? a.scheduled!.map((sc) => ({
669
+ label: `Edit schedule: ${formatDate(sc.scheduled_at, true)}`, onSelect: () => onReschedule!(a, sc.id),
670
+ })) : []),
671
  ...(a.type === "upload" ? [{ label: "Rename", onSelect: () => setRenaming({ id: a.id, title: a.title }) }] : []),
672
  ...(kit ? [{ label: "Open kit", onSelect: () => (onPickKit ? onPickKit(a) : navigate(`/app/launchpad/kits/${a.id}`)) }]
673
  : a.editable_video ? [{ label: "Open", onSelect: () => navigate(`/app/videos/${a.id}`) }] : []),
 
682
  </ul>
683
  {rendering && <RenderVideoModal video={rendering} onClose={() => setRendering(null)} onRendered={rendered} />}
684
  {previewing && <MediaPreviewModal a={previewing} onClose={() => setPreviewing(null)} />}
685
+ {audio && <AudioOnlyModal title={audio.title} downloadUrl={audio.download_url} onClose={() => setAudio(null)} />}
686
  {deleting && (
687
  <Modal title={deleteWording(deleting).title} onClose={() => !deleteBusy && setDeleting(null)}>
688
  <div className="stack">
frontend/src/components/launchpad/PostButton.tsx CHANGED
@@ -2,8 +2,9 @@ import { useState } from "react";
2
  import type { Artifact } from "../../api/types";
3
  import { ScheduleModal } from "../ScheduleModal";
4
 
5
- /** What can go out on X / LinkedIn: images, videos and text. Not audio (neither platform takes audio files). */
6
- const POSTABLE = new Set(["video", "quote_card", "carousel", "summary", "launch_kit", "mind_map"]);
 
7
 
8
  export function canPost(a: Artifact): boolean {
9
  if (!POSTABLE.has(a.type)) return false;
 
2
  import type { Artifact } from "../../api/types";
3
  import { ScheduleModal } from "../ScheduleModal";
4
 
5
+ /** What can go out on X / LinkedIn: images (a slide deck's slides too), videos and text. Not audio (neither platform
6
+ * takes audio files). */
7
+ const POSTABLE = new Set(["video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "slide_deck"]);
8
 
9
  export function canPost(a: Artifact): boolean {
10
  if (!POSTABLE.has(a.type)) return false;
frontend/src/components/slideDeckEdit.ts ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** The slide deck editor's rules: what a slide can gain or lose, how it changes to another layout, and what must not be
2
+ * left empty. They mirror the server (backend/app/slides/content.py: LIMITS and clamp_slide), which coerces whatever
3
+ * comes back anyway; these only keep the editor from offering what the server would undo. */
4
+ import type { EditSlide } from "../api/endpoints";
5
+
6
+ export type DeckFmt = "detailed" | "presenter";
7
+
8
+ /** The layouts a slide can have, in the order the editor lists them. */
9
+ export const LAYOUTS: { id: string; name: string }[] = [
10
+ { id: "title", name: "Title" },
11
+ { id: "section", name: "Section" },
12
+ { id: "points", name: "Points" },
13
+ { id: "two_column", name: "Two columns" },
14
+ { id: "stat", name: "Big number" },
15
+ { id: "quote", name: "Quote" },
16
+ { id: "agenda", name: "Agenda" },
17
+ { id: "closing", name: "Closing" },
18
+ ];
19
+
20
+ export const MAX_SLIDES = 30;
21
+
22
+ const max = (fmt: DeckFmt) => ({ points: fmt === "presenter" ? 4 : 5, agenda: 14, takeaways: 3, items: 4 });
23
+
24
+ const clone = (s: EditSlide): EditSlide => JSON.parse(JSON.stringify(s)) as EditSlide;
25
+
26
+ /** A new slide of `layout`, with placeholder text to type over. */
27
+ export function newSlide(layout: string, fmt: DeckFmt): EditSlide {
28
+ const blank: EditSlide = {
29
+ layout: "section", variant: "", kicker: "", heading: "New slide", lead: "", points: [],
30
+ left: { label: "", items: [] }, right: { label: "", items: [] }, stat: { value: "", label: "" },
31
+ quote: { text: "", by: "" }, takeaways: [], closing: "", notes: "", sources: [],
32
+ };
33
+ return convert(blank, layout, fmt);
34
+ }
35
+
36
+ /** The slide changed to another layout. Its words carry over (points become a column's items, a closing's
37
+ * takeaways...); what the new layout needs and the slide lacks is filled with placeholder text. The old layout's
38
+ * content stays in the slide, so changing back brings it back. */
39
+ export function convert(s: EditSlide, layout: string, fmt: DeckFmt): EditSlide {
40
+ const out = { ...clone(s), layout, variant: "" };
41
+ const m = max(fmt);
42
+ const words = [...s.points.map((p) => p.text), ...s.takeaways, ...s.left.items, ...s.right.items].filter(Boolean);
43
+ const some = (n: number, fallback: string[]) => (words.length >= 2 ? words : fallback).slice(0, n);
44
+ if (layout === "points" && out.points.length < 2) {
45
+ out.points = some(m.points, ["First point", "Second point", "Third point"]).map((text) => ({ term: "", text }));
46
+ } else if (layout === "agenda" && out.points.length < 2) {
47
+ out.points = some(m.agenda, ["First topic", "Second topic", "Third topic"]).map((text) => ({ term: "", text }));
48
+ } else if (layout === "two_column" && !(out.left.items.length && out.right.items.length)) {
49
+ const items = some(2 * m.items, ["A point on this side", "Another point", "A point on that side", "Another point"]);
50
+ const half = Math.ceil(items.length / 2);
51
+ out.left = { label: out.left.label || "One side", items: items.slice(0, half).slice(0, m.items) };
52
+ out.right = { label: out.right.label || "The other side", items: items.slice(half).slice(0, m.items) };
53
+ } else if (layout === "stat" && !(out.stat.value && out.stat.label)) {
54
+ out.stat = { value: out.stat.value || "42%", label: out.stat.label || words[0] || "What this number means" };
55
+ } else if (layout === "quote" && !out.quote.text) {
56
+ out.quote = { text: words[0] || out.lead || "A line worth quoting.", by: out.quote.by };
57
+ } else if (layout === "closing" && !out.takeaways.length && !out.closing) {
58
+ out.takeaways = some(m.takeaways, ["First takeaway", "Second takeaway", "Third takeaway"]);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ export type AddAction = { id: string; label: string; apply: (s: EditSlide) => EditSlide };
64
+
65
+ /** The text boxes a slide can gain: only those its design has a place for (`slots`, from the server) and that it
66
+ * does not have yet, within the format's limits. */
67
+ export function addActions(s: EditSlide, slots: string[], fmt: DeckFmt): AddAction[] {
68
+ const m = max(fmt);
69
+ const has = (slot: string) => slots.includes(slot);
70
+ const act = (id: string, label: string, change: (c: EditSlide) => void): AddAction => ({
71
+ id, label, apply: (from) => {
72
+ const c = clone(from);
73
+ change(c);
74
+ return c;
75
+ },
76
+ });
77
+ const out: AddAction[] = [];
78
+ if (has("kicker") && !s.kicker) out.push(act("kicker", "Kicker", (c) => (c.kicker = "Kicker")));
79
+ if (has("lead") && !s.lead) out.push(act("lead", "Subtitle", (c) => (c.lead = "A sentence that adds context.")));
80
+ if (s.layout === "points" && has("body") && s.points.length < m.points) {
81
+ out.push(act("point", "Bullet", (c) => c.points.push({ term: "", text: "New point" })));
82
+ }
83
+ if (s.layout === "agenda" && has("body") && s.points.length < m.agenda) {
84
+ out.push(act("point", "Topic", (c) => c.points.push({ term: "", text: "New topic" })));
85
+ }
86
+ if (s.layout === "two_column") {
87
+ for (const side of ["left", "right"] as const) {
88
+ const name = side === "left" ? "Left" : "Right";
89
+ if (!s[side].label) out.push(act(`${side}-label`, `${name} heading`, (c) => (c[side].label = "Heading")));
90
+ if (s[side].items.length < m.items) out.push(act(`${side}-item`, `${name} item`, (c) => c[side].items.push("New item")));
91
+ }
92
+ }
93
+ if (s.layout === "quote" && has("by") && !s.quote.by) out.push(act("by", "Author", (c) => (c.quote.by = "Name")));
94
+ if (s.layout === "closing") {
95
+ if (has("body") && s.takeaways.length < m.takeaways) out.push(act("takeaway", "Takeaway", (c) => c.takeaways.push("New takeaway")));
96
+ if (has("closing") && !s.closing) out.push(act("closing", "Closing line", (c) => (c.closing = "One line to end on.")));
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** The text boxes on a slide that can be removed (a point only while the slide keeps enough of them; the heading, a
102
+ * stat or a quote never: the layout needs them). Each is the `data-f` of the text drawn in that box. */
103
+ export function removable(s: EditSlide): string[] {
104
+ const out: string[] = [];
105
+ if (s.kicker) out.push("kicker");
106
+ if (s.lead) out.push("lead");
107
+ if (s.quote.by) out.push("quote.by");
108
+ if (s.closing && s.takeaways.length) out.push("closing");
109
+ const minPoints = 2;
110
+ s.points.forEach((p, i) => {
111
+ if (p.term) out.push(`points.${i}.term`);
112
+ if (s.points.length > minPoints) out.push(`points.${i}.text`);
113
+ });
114
+ if (s.takeaways.length > 1 || s.closing) s.takeaways.forEach((_, i) => out.push(`takeaways.${i}`));
115
+ for (const side of ["left", "right"] as const) {
116
+ if (s[side].label) out.push(`${side}.label`);
117
+ if (s[side].items.length > 1) s[side].items.forEach((_, i) => out.push(`${side}.items.${i}`));
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** The slide without the text box at `field`: a point, takeaway or column item goes; other text is emptied. */
123
+ export function removeField(s: EditSlide, field: string): EditSlide {
124
+ const c = clone(s);
125
+ const [a, b, d] = field.split(".");
126
+ if (a === "points" && d === "text") c.points.splice(Number(b), 1);
127
+ else if (a === "points" && d === "term") c.points[Number(b)].term = "";
128
+ else if (a === "takeaways") c.takeaways.splice(Number(b), 1);
129
+ else if ((a === "left" || a === "right") && b === "items") c[a].items.splice(Number(d), 1);
130
+ else if ((a === "left" || a === "right") && b === "label") c[a].label = "";
131
+ else if (a === "quote" && b === "by") c.quote.by = "";
132
+ else if (a === "kicker" || a === "lead" || a === "closing") c[a] = "";
133
+ return c;
134
+ }
135
+
136
+ /** The slide with the text at `field` ("points.2.text", "left.items.0", "heading") set to `text`. */
137
+ export function setText(s: EditSlide, field: string, text: string): EditSlide {
138
+ const c = clone(s);
139
+ const path = field.split(".");
140
+ let at: Record<string, unknown> | unknown[] = c as unknown as Record<string, unknown>;
141
+ for (const k of path.slice(0, -1)) at = (at as Record<string, unknown>)[k] as Record<string, unknown>;
142
+ const last = path[path.length - 1];
143
+ if (Array.isArray(at)) at[Number(last)] = text;
144
+ else if (at && typeof at === "object") at[last] = text;
145
+ return c;
146
+ }
147
+
148
+ const blank = (t: string) => !t.replace(/\s+/g, "");
149
+
150
+ /** What stops the deck from being saved as it is ("Slide 3: the heading can't be empty."), or null. */
151
+ export function problem(slides: EditSlide[]): string | null {
152
+ for (const [i, s] of slides.entries()) {
153
+ const at = (what: string) => `Slide ${i + 1}: ${what} can't be empty.`;
154
+ if (blank(s.heading) && s.layout !== "quote" && s.layout !== "closing") return at("the heading");
155
+ if (s.layout === "points" || s.layout === "agenda") {
156
+ const n = s.points.findIndex((p) => blank(p.text));
157
+ if (n >= 0) return at(`${s.layout === "agenda" ? "topic" : "point"} ${n + 1}`);
158
+ }
159
+ if (s.layout === "two_column") {
160
+ for (const side of ["left", "right"] as const) {
161
+ const n = s[side].items.findIndex(blank);
162
+ if (n >= 0) return at(`the ${side} column's item ${n + 1}`);
163
+ }
164
+ }
165
+ if (s.layout === "stat" && (blank(s.stat.value) || blank(s.stat.label))) return at(blank(s.stat.value) ? "the number" : "the number's label");
166
+ if (s.layout === "quote" && blank(s.quote.text)) return at("the quote");
167
+ if (s.layout === "closing") {
168
+ const n = s.takeaways.findIndex(blank);
169
+ if (n >= 0) return at(`takeaway ${n + 1}`);
170
+ }
171
+ }
172
+ return null;
173
+ }
frontend/src/components/ui.tsx CHANGED
@@ -69,17 +69,21 @@ export function Modal({
69
  actions?: ReactNode;
70
  }) {
71
  const ref = useRef<HTMLDivElement>(null);
 
 
 
 
72
  useEffect(() => {
73
  const onKey = (e: KeyboardEvent) => {
74
  if (e.key !== "Escape") return;
75
  // A modal opened from a modal (a picker over the composer): Escape closes only the top one.
76
  const open = document.querySelectorAll(".modal-backdrop");
77
- if (open[open.length - 1] === ref.current?.parentElement) onClose();
78
  };
79
  document.addEventListener("keydown", onKey);
80
- ref.current?.focus();
81
  return () => document.removeEventListener("keydown", onKey);
82
- }, [onClose]);
83
  // Rendered into <body>: a parent with backdrop-filter or transform (cards have one) would otherwise become the
84
  // containing block of this fixed backdrop and trap the modal inside that card.
85
  return createPortal(
@@ -171,6 +175,44 @@ export function CopyButton({ text, label = "Copy" }: { text: string; label?: str
171
  }
172
 
173
  /** Two step destructive button: first click arms it, second confirms. No browser dialogs. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  export function ConfirmButton({
175
  onConfirm,
176
  children,
 
69
  actions?: ReactNode;
70
  }) {
71
  const ref = useRef<HTMLDivElement>(null);
72
+ // The latest onClose, so a parent passing a new function each render neither re-binds Escape nor (worse) pulls focus
73
+ // back to the dialog, away from whatever is being typed in.
74
+ const close = useRef(onClose);
75
+ close.current = onClose;
76
  useEffect(() => {
77
  const onKey = (e: KeyboardEvent) => {
78
  if (e.key !== "Escape") return;
79
  // A modal opened from a modal (a picker over the composer): Escape closes only the top one.
80
  const open = document.querySelectorAll(".modal-backdrop");
81
+ if (open[open.length - 1] === ref.current?.parentElement) close.current();
82
  };
83
  document.addEventListener("keydown", onKey);
84
+ ref.current?.focus(); // once, on opening
85
  return () => document.removeEventListener("keydown", onKey);
86
+ }, []);
87
  // Rendered into <body>: a parent with backdrop-filter or transform (cards have one) would otherwise become the
88
  // containing block of this fixed backdrop and trap the modal inside that card.
89
  return createPortal(
 
175
  }
176
 
177
  /** Two step destructive button: first click arms it, second confirms. No browser dialogs. */
178
+ /** "Delete this?": deletes only on confirm, shows what went wrong if it fails, and closes once it is gone. */
179
+ export function ConfirmDeleteModal({ heading, name, onCancel, onConfirm }: {
180
+ heading: string;
181
+ name: string;
182
+ onCancel: () => void;
183
+ onConfirm: () => Promise<void>;
184
+ }) {
185
+ const [busy, setBusy] = useState(false);
186
+ const [error, setError] = useState<string | null>(null);
187
+ const confirm = async () => {
188
+ setBusy(true);
189
+ setError(null);
190
+ try {
191
+ await onConfirm();
192
+ onCancel(); // gone: close the warning
193
+ } catch (e) {
194
+ setError(errorMessage(e));
195
+ setBusy(false);
196
+ }
197
+ };
198
+ return (
199
+ <Modal title={heading} onClose={busy ? () => undefined : onCancel}>
200
+ <div className="stack sd-confirm">
201
+ <p>
202
+ <strong>{name}</strong> will be deleted for good. This cannot be undone.
203
+ </p>
204
+ {error && <p className="error-text" role="alert">{error}</p>}
205
+ <div className="row sd-confirm-actions">
206
+ <button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
207
+ <button type="button" className="btn btn-danger" onClick={confirm} disabled={busy} autoFocus>
208
+ {busy ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Deleting...</>) : "Delete"}
209
+ </button>
210
+ </div>
211
+ </div>
212
+ </Modal>
213
+ );
214
+ }
215
+
216
  export function ConfirmButton({
217
  onConfirm,
218
  children,
frontend/src/pages/Archive.tsx CHANGED
@@ -1,17 +1,18 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { Link, useSearchParams } from "react-router-dom";
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact } from "../api/types";
5
  import { ArtifactRow } from "../components/ArtifactRow";
6
  import { Reader } from "../components/Reader";
7
  import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
8
  import Videos from "./Videos";
9
- import VoiceProfile from "./VoiceProfile";
10
  import { canPost, PostButton } from "../components/launchpad/PostButton";
 
11
 
12
- type Filter = "all" | "report" | "summary" | "quiz" | "flashcards" | "mind_map" | "infographic";
13
- /** The Library's tabs: kinds of things made (a filter on one list), plus your videos and your voices (their own pages). */
14
- type Tab = Filter | "videos" | "voices";
 
15
  const TABS: { id: Tab; label: string }[] = [
16
  { id: "all", label: "All" },
17
  { id: "report", label: "Reports" },
@@ -20,13 +21,15 @@ const TABS: { id: Tab; label: string }[] = [
20
  { id: "flashcards", label: "Flashcards" },
21
  { id: "mind_map", label: "Mind Constellations" },
22
  { id: "infographic", label: "Infographics" },
 
 
23
  { id: "videos", label: "Videos" },
24
- { id: "voices", label: "Voices" },
25
  ];
26
  const PAGE = 30;
27
 
28
  export default function Archive() {
29
  const [params, setParams] = useSearchParams();
 
30
  const tab: Tab = TABS.find((t) => t.id === params.get("tab"))?.id ?? "all";
31
  // The tab lives in the URL (?tab=videos), so the sidebar, redirects and Back land on it.
32
  const pickTab = (t: Tab) => setParams(t === "all" ? {} : { tab: t });
@@ -35,11 +38,22 @@ export default function Archive() {
35
  <div className="page-wrap">
36
  <PageHeader eyebrow="Library" title="Everything you have made" />
37
  <Tabs<Tab> tabs={TABS} value={tab} onChange={pickTab} />
38
- {tab === "videos" ? <Videos /> : tab === "voices" ? <VoiceProfile /> : <MadeList filter={tab} />}
39
  </div>
40
  );
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
43
  /** What was made, of one kind (or all), searchable. */
44
  function MadeList({ filter }: { filter: Filter }) {
45
  const [q, setQ] = useState("");
@@ -48,19 +62,31 @@ function MadeList({ filter }: { filter: Filter }) {
48
  const [failed, setFailed] = useState(false);
49
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
50
 
 
 
 
51
  const load = useCallback(
52
- (offset = 0) =>
53
- artifactsApi
 
54
  .list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset })
55
  .then((p) => {
 
56
  setFailed(false);
57
  setTotal(p.total);
58
  setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
59
  })
60
- .catch(() => setFailed(true)),
 
61
  [filter, q],
62
  );
63
 
 
 
 
 
 
 
64
  useEffect(() => {
65
  const t = setTimeout(() => load(0), 200);
66
  return () => clearTimeout(t);
@@ -76,7 +102,7 @@ function MadeList({ filter }: { filter: Filter }) {
76
  <EmptyState title="Could not load your library" body="Something went wrong reaching the server."
77
  action={<button className="btn btn-primary" onClick={() => load(0)}>Try again</button>} />
78
  )}
79
- {items?.length === 0 && <EmptyState title="Nothing here yet" body="Reports, summaries, quizzes, flashcards, Mind Constellations, audio and more that you create show up here."
80
  action={
81
  <Link className="btn btn-primary" to="/app/studio">
82
  Create something
@@ -88,7 +114,8 @@ function MadeList({ filter }: { filter: Filter }) {
88
  <ArtifactRow
89
  key={a.id}
90
  artifact={a}
91
- actions={(art) => (canPost(art) ? <PostButton artifactId={art.id} /> : null)}
 
92
  onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
93
  onRemoved={(id) => {
94
  setItems((list) => (list ?? []).filter((x) => x.id !== id));
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Link, Navigate, useSearchParams } from "react-router-dom";
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact } from "../api/types";
5
  import { ArtifactRow } from "../components/ArtifactRow";
6
  import { Reader } from "../components/Reader";
7
  import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
8
  import Videos from "./Videos";
 
9
  import { canPost, PostButton } from "../components/launchpad/PostButton";
10
+ import { AudioOnlyModal } from "../components/launchpad/Attachment";
11
 
12
+ type Filter = "all" | "report" | "summary" | "quiz" | "flashcards" | "mind_map" | "infographic" | "slide_deck" | "audio_overview";
13
+ /** The Library's tabs: kinds of things made (a filter on one list), plus your videos (their own page). Voices moved to
14
+ * the sidebar (Manage voices, /app/voices); an old ?tab=voices link goes there. */
15
+ type Tab = Filter | "videos";
16
  const TABS: { id: Tab; label: string }[] = [
17
  { id: "all", label: "All" },
18
  { id: "report", label: "Reports" },
 
21
  { id: "flashcards", label: "Flashcards" },
22
  { id: "mind_map", label: "Mind Constellations" },
23
  { id: "infographic", label: "Infographics" },
24
+ { id: "slide_deck", label: "Slide decks" },
25
+ { id: "audio_overview", label: "Audio overviews" },
26
  { id: "videos", label: "Videos" },
 
27
  ];
28
  const PAGE = 30;
29
 
30
  export default function Archive() {
31
  const [params, setParams] = useSearchParams();
32
+ if (params.get("tab") === "voices") return <Navigate to="/app/voices" replace />;
33
  const tab: Tab = TABS.find((t) => t.id === params.get("tab"))?.id ?? "all";
34
  // The tab lives in the URL (?tab=videos), so the sidebar, redirects and Back land on it.
35
  const pickTab = (t: Tab) => setParams(t === "all" ? {} : { tab: t });
 
38
  <div className="page-wrap">
39
  <PageHeader eyebrow="Library" title="Everything you have made" />
40
  <Tabs<Tab> tabs={TABS} value={tab} onChange={pickTab} />
41
+ {tab === "videos" ? <Videos /> : <MadeList filter={tab} />}
42
  </div>
43
  );
44
  }
45
 
46
+ /** "Post" on an audio overview: X and LinkedIn take no audio files, so it explains that and offers the download. */
47
+ function AudioPostButton({ artifact }: { artifact: Artifact }) {
48
+ const [open, setOpen] = useState(false);
49
+ return (
50
+ <>
51
+ <button type="button" className="btn btn-small" onClick={() => setOpen(true)}>Post</button>
52
+ {open && <AudioOnlyModal title={artifact.title} downloadUrl={artifact.download_url} onClose={() => setOpen(false)} />}
53
+ </>
54
+ );
55
+ }
56
+
57
  /** What was made, of one kind (or all), searchable. */
58
  function MadeList({ filter }: { filter: Filter }) {
59
  const [q, setQ] = useState("");
 
62
  const [failed, setFailed] = useState(false);
63
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
64
 
65
+ // The tab and search the list is showing: an answer for any other (a tab just left) is dropped when it arrives late.
66
+ const showing = useRef("");
67
+ showing.current = `${filter}|${q}`;
68
  const load = useCallback(
69
+ (offset = 0) => {
70
+ const asked = `${filter}|${q}`;
71
+ return artifactsApi
72
  .list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset })
73
  .then((p) => {
74
+ if (showing.current !== asked) return;
75
  setFailed(false);
76
  setTotal(p.total);
77
  setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
78
  })
79
+ .catch(() => showing.current === asked && setFailed(true));
80
+ },
81
  [filter, q],
82
  );
83
 
84
+ // Another tab: the loader until its items are fetched, never the last tab's items meanwhile.
85
+ useEffect(() => {
86
+ setItems(null);
87
+ setFailed(false);
88
+ }, [filter]);
89
+
90
  useEffect(() => {
91
  const t = setTimeout(() => load(0), 200);
92
  return () => clearTimeout(t);
 
102
  <EmptyState title="Could not load your library" body="Something went wrong reaching the server."
103
  action={<button className="btn btn-primary" onClick={() => load(0)}>Try again</button>} />
104
  )}
105
+ {items?.length === 0 && <EmptyState title="Nothing here yet" body="Reports, summaries, quizzes, flashcards, Mind Constellations, slide decks, audio and more that you create show up here."
106
  action={
107
  <Link className="btn btn-primary" to="/app/studio">
108
  Create something
 
114
  <ArtifactRow
115
  key={a.id}
116
  artifact={a}
117
+ actions={(art) => (canPost(art) ? <PostButton artifactId={art.id} />
118
+ : art.type === "audio_overview" && art.status === "ready" ? <AudioPostButton artifact={art} /> : null)}
119
  onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
120
  onRemoved={(id) => {
121
  setItems((list) => (list ?? []).filter((x) => x.id !== id));
frontend/src/pages/Launchpad.tsx CHANGED
@@ -52,8 +52,10 @@ function ItemModal({
52
  const locked = item.status === "posted" || item.status === "publishing";
53
  const options = (accounts?.accounts ?? []).filter((a) => a.platform === item.platform);
54
 
 
 
55
  const save = async () => {
56
- if (moved && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead.");
57
  setBusy(true);
58
  setError(null);
59
  try {
@@ -62,7 +64,8 @@ function ItemModal({
62
  await launchpadApi.update(item.id, {
63
  content: clean[0],
64
  thread: clean.slice(1),
65
- ...(moved ? { local_time: when, timezone: userTimeZone() } : {}),
 
66
  social_account_id: accountId || null,
67
  ...((attached?.id ?? null) !== item.artifact_id ? { artifact_id: attached?.id ?? null } : {}),
68
  }),
@@ -143,7 +146,7 @@ function ItemModal({
143
  </label>
144
  ))}
145
  {!locked && (
146
- <div className="row">
147
  <div className="field">
148
  <span>When</span>
149
  <SlotPicker value={when} onChange={setWhen} />
@@ -164,7 +167,7 @@ function ItemModal({
164
  </div>
165
  )}
166
  {error && <p className="error-text">{error}</p>}
167
- <div className="row between">
168
  <div className="row">
169
  <CopyButton text={posts.join("\n\n")} />
170
  <ConfirmButton
@@ -201,8 +204,9 @@ function ItemModal({
201
  >
202
  {item.auto_post ? "Publish now" : "Send reminder now"}
203
  </button>
204
- <button className="btn btn-primary" disabled={busy} onClick={save}>
205
- Save
 
206
  </button>
207
  </div>
208
  )}
@@ -408,7 +412,6 @@ export default function Launchpad() {
408
  </button>
409
  </PageHeader>
410
  {notice && <p className="notice">{notice}</p>}
411
- {publishing && <p className="notice">Publishing... uploading a video can take a few minutes.</p>}
412
  {error && <p className="error-text">{error}</p>}
413
 
414
 
@@ -478,7 +481,14 @@ export default function Launchpad() {
478
  {items.length === 0 && <li className="muted">Nothing scheduled. Quiet moon tonight.</li>}
479
  </ul>
480
  )}
481
- {items && <p className="mono muted small">{upcoming.length} upcoming in view</p>}
 
 
 
 
 
 
 
482
  </section>
483
 
484
  <ConnectionsPanel accounts={accounts} onConnect={connect} onChanged={async () => {
@@ -495,22 +505,75 @@ export default function Launchpad() {
495
 
496
  /** /app/launchpad/new: what to launch. Something from the Library opens the composer with it attached; Create a
497
  * launch kit goes to the kit pages (and is scheduled from the kit). */
 
 
498
  export function ScheduleLaunch() {
499
  const navigate = useNavigate();
500
  const [picked, setPicked] = useState<PostableArtifact | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  return (
502
  <div className="page-wrap">
503
  <BackArrow fallback="/app/launchpad" />
504
  <PageHeader eyebrow="Launchpad" title="Schedule a launch" />
 
505
  <section className="card">
506
  <LibraryPicker fill onPick={setPicked} onPickKit={(k) => navigate(`/app/launchpad/kits/${k.id}`)}
507
- onCreateKit={() => navigate("/app/launchpad/kits")} />
 
 
 
 
 
508
  </section>
509
  {picked && (
510
  <ScheduleModal platform="linkedin" posts={[]} artifact={picked} artifactId={picked.id}
511
  onClose={() => setPicked(null)}
512
  onScheduled={() => navigate("/app/launchpad")} />
513
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  </div>
515
  );
516
  }
 
52
  const locked = item.status === "posted" || item.status === "publishing";
53
  const options = (accounts?.accounts ?? []).filter((a) => a.platform === item.platform);
54
 
55
+ // Not on the calendar to go out (a draft, failed, reminded or paused post): Save puts it back, at the time shown.
56
+ const reschedules = item.status !== "scheduled";
57
  const save = async () => {
58
+ if ((moved || reschedules) && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead.");
59
  setBusy(true);
60
  setError(null);
61
  try {
 
64
  await launchpadApi.update(item.id, {
65
  content: clean[0],
66
  thread: clean.slice(1),
67
+ ...(moved || reschedules ? { local_time: when, timezone: userTimeZone() } : {}),
68
+ ...(reschedules ? { status: "scheduled" as const } : {}),
69
  social_account_id: accountId || null,
70
  ...((attached?.id ?? null) !== item.artifact_id ? { artifact_id: attached?.id ?? null } : {}),
71
  }),
 
146
  </label>
147
  ))}
148
  {!locked && (
149
+ <div className="lp-edit-when">
150
  <div className="field">
151
  <span>When</span>
152
  <SlotPicker value={when} onChange={setWhen} />
 
167
  </div>
168
  )}
169
  {error && <p className="error-text">{error}</p>}
170
+ <div className="row between lp-edit-actions">
171
  <div className="row">
172
  <CopyButton text={posts.join("\n\n")} />
173
  <ConfirmButton
 
204
  >
205
  {item.auto_post ? "Publish now" : "Send reminder now"}
206
  </button>
207
+ <button className="btn btn-primary" disabled={busy} onClick={save}
208
+ title={reschedules ? "Save it and put it back on the calendar at this time" : undefined}>
209
+ {reschedules ? "Reschedule" : "Save"}
210
  </button>
211
  </div>
212
  )}
 
412
  </button>
413
  </PageHeader>
414
  {notice && <p className="notice">{notice}</p>}
 
415
  {error && <p className="error-text">{error}</p>}
416
 
417
 
 
481
  {items.length === 0 && <li className="muted">Nothing scheduled. Quiet moon tonight.</li>}
482
  </ul>
483
  )}
484
+ {items && (
485
+ <div className="lp-cal-foot">
486
+ <p className="mono muted small">{upcoming.length} upcoming in view</p>
487
+ <p className="muted small lp-cal-hint">
488
+ Click a post on the calendar to edit it: change its words or time, reschedule it, or publish it now.
489
+ </p>
490
+ </div>
491
+ )}
492
  </section>
493
 
494
  <ConnectionsPanel accounts={accounts} onConnect={connect} onChanged={async () => {
 
505
 
506
  /** /app/launchpad/new: what to launch. Something from the Library opens the composer with it attached; Create a
507
  * launch kit goes to the kit pages (and is scheduled from the kit). */
508
+ /** Schedule a launch: pick something from the Library to post. Something already scheduled says Reschedule and opens
509
+ * that post (the calendar's editor), and its menu can schedule it once more, starting from a copy of that post. */
510
  export function ScheduleLaunch() {
511
  const navigate = useNavigate();
512
  const [picked, setPicked] = useState<PostableArtifact | null>(null);
513
+ // A post already on the calendar: opened to edit (rescheduled), or copied into a new one (a duplicate schedule).
514
+ const [editing, setEditing] = useState<CalendarItem | null>(null);
515
+ const [copying, setCopying] = useState<{ a: PostableArtifact; item: CalendarItem } | null>(null);
516
+ const [accounts, setAccounts] = useState<SocialAccounts | null>(null);
517
+ const [refreshKey, setRefreshKey] = useState(0);
518
+ const [error, setError] = useState<string | null>(null);
519
+ const [openingId, setOpeningId] = useState<string | null>(null); // the row whose post is loading
520
+ // The accounts the editor needs, loaded once with the page rather than on every click.
521
+ useEffect(() => {
522
+ launchpadApi.accounts().then(setAccounts, () => undefined);
523
+ }, []);
524
+ const scheduledItem = async (artifactId: string, id: string) => {
525
+ setError(null);
526
+ setOpeningId(artifactId);
527
+ try {
528
+ const [all, acc] = await Promise.all([launchpadApi.items({ status: "scheduled" }),
529
+ accounts ? Promise.resolve(accounts) : launchpadApi.accounts()]);
530
+ setAccounts(acc);
531
+ const found = all.find((i) => i.id === id);
532
+ if (!found) {
533
+ setError("That post isn't scheduled anymore.");
534
+ setRefreshKey((k) => k + 1);
535
+ }
536
+ return found ?? null;
537
+ } catch (e) {
538
+ setError(errorMessage(e));
539
+ return null;
540
+ } finally {
541
+ setOpeningId(null);
542
+ }
543
+ };
544
  return (
545
  <div className="page-wrap">
546
  <BackArrow fallback="/app/launchpad" />
547
  <PageHeader eyebrow="Launchpad" title="Schedule a launch" />
548
+ {error && <p className="error-text">{error}</p>}
549
  <section className="card">
550
  <LibraryPicker fill onPick={setPicked} onPickKit={(k) => navigate(`/app/launchpad/kits/${k.id}`)}
551
+ onCreateKit={() => navigate("/app/launchpad/kits")} refreshKey={refreshKey} openingId={openingId}
552
+ onReschedule={async (a, id) => setEditing(await scheduledItem(a.id, id))}
553
+ onDuplicate={async (a, id) => {
554
+ const item = await scheduledItem(a.id, id);
555
+ if (item) setCopying({ a, item });
556
+ }} />
557
  </section>
558
  {picked && (
559
  <ScheduleModal platform="linkedin" posts={[]} artifact={picked} artifactId={picked.id}
560
  onClose={() => setPicked(null)}
561
  onScheduled={() => navigate("/app/launchpad")} />
562
  )}
563
+ {copying && (
564
+ <ScheduleModal platform={copying.item.platform} posts={[copying.item.content, ...copying.item.thread]}
565
+ artifact={copying.a} artifactId={copying.a.id}
566
+ onClose={() => setCopying(null)}
567
+ onScheduled={() => {
568
+ setCopying(null);
569
+ setRefreshKey((k) => k + 1);
570
+ }} />
571
+ )}
572
+ {editing && (
573
+ <ItemModal item={editing} accounts={accounts} onClose={() => setEditing(null)}
574
+ onChanged={() => setRefreshKey((k) => k + 1)}
575
+ onReconnect={() => navigate("/app/launchpad")} />
576
+ )}
577
  </div>
578
  );
579
  }
frontend/src/pages/NotebookView.tsx CHANGED
@@ -1,20 +1,22 @@
1
  import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
2
  import { Link, useParams, useSearchParams } from "react-router-dom";
3
- import { artifactsApi, notebooksApi, videoVoicesApi, type GenerateBody } from "../api/endpoints";
4
  import { streamSSE } from "../api/stream";
5
- import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook, VideoSavedVoice } from "../api/types";
6
  import { AnswerFeedback } from "../components/AnswerFeedback";
7
  import { ArtifactCard, CitationList } from "../components/ArtifactCard";
8
  import { Markdown } from "../components/Markdown";
9
  import { DocPicker } from "../components/DocPicker";
10
- import { Dropdown } from "../components/Dropdown";
11
- import { ChevronIcon, FlashcardsIcon, HeadphonesIcon, InfographicIcon, QuizIcon, ReportIcon, TelescopeIcon, VideoIcon } from "../components/icons/Icons";
12
  import { MindMapDialog, MindMapExplorer, MindMapRow } from "../components/MindMap";
13
  import { FlashcardsDialog, type FlashcardsRequest } from "../components/Flashcards";
14
  import { InfographicDialog, type InfographicRequest } from "../components/Infographic";
 
 
15
  import { QuizDialog, type QuizRequest } from "../components/Quiz";
16
  import { MAX_STUDY_POSTS, prefetchFocus } from "../components/SourceFocusFields";
17
  import { ReportDialog, type ReportRequest } from "../components/ReportDialog";
 
18
  import { Reader } from "../components/Reader";
19
  import { seedDocs } from "../components/video/sourceCache";
20
  import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
@@ -45,7 +47,7 @@ function readChatRailOpen(): boolean {
45
  }
46
 
47
  /** Create: one click per format with sensible defaults, options folded away; then what was made from this notebook. */
48
- function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, disabled, quizOpen, onQuizClose, onCreateQuiz, cardsOpen, onCardsClose, onCreateCards, infographicOpen, onInfographicClose, onCreateInfographic, reportOpen, onReportClose, onCreateReport, onCreateVideo }: {
49
  notebookId: string;
50
  notebookTitle: string;
51
  docs: { id: string; title: string; locked?: boolean }[];
@@ -61,67 +63,41 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
61
  infographicOpen: boolean;
62
  onInfographicClose: () => void;
63
  onCreateInfographic: () => void;
 
 
 
 
 
 
64
  reportOpen: boolean;
65
  onReportClose: () => void;
66
  onCreateReport: () => void;
67
  onCreateVideo: () => void;
68
  }) {
69
  const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
70
- const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
71
- const [minutes, setMinutes] = useState(6);
72
  const [error, setError] = useState<string | null>(null);
73
- const [busy, setBusy] = useState(false);
74
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
75
- const [audioOpen, setAudioOpen] = useState(false); // the Audio overview tile is opened to its options
76
- // The two hosts' voices, picked from the workspace's voices (loaded when the options first open).
77
- const [hostVoices, setHostVoices] = useState<VideoSavedVoice[] | null>(null);
78
- const [hosts, setHosts] = useState<[string, string]>(["", ""]);
79
  const [mapDialog, setMapDialog] = useState(false);
80
  const [waitingMap, setWaitingMap] = useState<string | null>(null); // a map the user just asked for: open it when ready
81
  const [exploring, setExploring] = useState<Artifact | null>(null);
82
 
83
  const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
84
 
85
- useEffect(() => {
86
- if (!audioOpen || hostVoices) return;
87
- videoVoicesApi.list().then((r) => {
88
- setHostVoices(r.saved);
89
- // The first two voices by default; with just one, it reads both parts.
90
- setHosts([r.saved[0]?.voice_id ?? "", (r.saved[1] ?? r.saved[0])?.voice_id ?? ""]);
91
- }).catch(() => setHostVoices([])); // none to pick: the server's default voices
92
- }, [audioOpen, hostVoices]);
93
-
94
- /** Pick a host's voice; the other host moves off it, so the two never share one (when there are two to pick). */
95
- const pickHost = (i: 0 | 1, voiceId: string) =>
96
- setHosts((cur) => {
97
- const next: [string, string] = [...cur];
98
- next[i] = voiceId;
99
- const other = i === 0 ? 1 : 0;
100
- if (next[other] === voiceId) next[other] = hostVoices?.find((v) => v.voice_id !== voiceId)?.voice_id ?? voiceId;
101
- return next;
102
- });
103
  useEffect(() => {
104
  load();
105
  }, [load]);
106
 
 
 
 
 
 
107
  // The focus suggestions the study dialogs show, made now so they are waiting when a dialog opens.
108
  const startIds = docs.filter((d) => !d.locked).slice(0, MAX_STUDY_POSTS).map((d) => d.id).join(",");
109
  useEffect(() => {
110
  if (startIds) prefetchFocus(notebookId, startIds.split(","));
111
  }, [notebookId, startIds]);
112
 
113
- const make = async (body: Omit<GenerateBody, "notebook_id">) => {
114
- setBusy(true);
115
- setError(null);
116
- try {
117
- const a = await artifactsApi.generate({ ...body, notebook_id: notebookId });
118
- setArtifacts((list) => [a, ...(list ?? [])]);
119
- } catch (e) {
120
- setError(errorMessage(e));
121
- } finally {
122
- setBusy(false);
123
- }
124
- };
125
 
126
  // Every "make" from a dialog works the same way: the dialog closes at once, a card in the panel says it is starting, the
127
  // artifact replaces it as soon as the server has it (and shows its own progress), and a failure is shown in the panel.
@@ -156,6 +132,10 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
156
  launch("report", onReportClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "report" }));
157
  const makeInfographic = (body: InfographicRequest) =>
158
  launch("infographic", onInfographicClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "infographic" }));
 
 
 
 
159
  const makeCards = (body: FlashcardsRequest) =>
160
  launch("flashcards", onCardsClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "flashcards" }));
161
 
@@ -163,7 +143,7 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
163
  const others = artifacts?.filter((a) => a.type !== "mind_map");
164
 
165
  const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });
166
- const off = disabled || busy;
167
 
168
  return (
169
  <div className="stack">
@@ -183,61 +163,26 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
183
  <circle cx="58" cy="18" r="2" fill="currentColor" />
184
  </svg>
185
  </button>
186
- {/* Opens its options (format, length) right under it; the audio is made from there */}
187
- <button className={`nbv-make-main${audioOpen ? " open" : ""}`} disabled={off} aria-expanded={audioOpen}
188
- onClick={() => setAudioOpen(!audioOpen)}>
189
- <span className="nbv-make-label"><HeadphonesIcon size={18} /> Audio overview</span>
190
- <span className="nbv-make-meta">
191
- <ChevronIcon size={16} className={`chevron${audioOpen ? " open" : ""}`} />
192
- </span>
193
  </button>
194
- {audioOpen && (
195
- <div className="nbv-audio-opts">
196
- <div className="nbv-audio-row">
197
- <div className="field">
198
- <span className="small muted">Format</span>
199
- <Dropdown<typeof format> label="Audio format" value={format} onChange={setFormat}
200
- options={[
201
- { value: "deep_dive", label: "Deep dive" },
202
- { value: "brief", label: "Brief" },
203
- { value: "debate", label: "Debate" },
204
- ]} />
205
- </div>
206
- <div className="field">
207
- <span className="small muted">Length</span>
208
- <Dropdown<string> label="Length" value={String(minutes)} onChange={(v) => setMinutes(Number(v))}
209
- options={[3, 6, 10, 15].map((m) => ({ value: String(m), label: `${m} min` }))} />
210
- </div>
211
- {hostVoices && hostVoices.length > 0 && ([0, 1] as const).map((i) => (
212
- <div key={i} className="field">
213
- <span className="small muted">Host {i + 1}</span>
214
- <Dropdown<string> label={`Host ${i + 1} voice`} value={hosts[i]} onChange={(v) => pickHost(i, v)}
215
- options={hostVoices.map((v) => ({ value: v.voice_id, label: v.name }))} />
216
- </div>
217
- ))}
218
- </div>
219
- <button className="btn btn-primary btn-small" disabled={off || (audioOpen && hostVoices === null)}
220
- onClick={() => make({ type: "audio_overview", format, minutes,
221
- ...(hosts[0] ? { host_a: hosts[0], host_b: hosts[1] || hosts[0] } : {}) })
222
- .then(() => setAudioOpen(false))}>
223
- {busy ? "Starting..." : "Create audio overview"}
224
- </button>
225
- </div>
226
- )}
227
  <button disabled={off} onClick={onCreateVideo}>
228
- <VideoIcon size={18} /> Video
229
  </button>
230
  <button disabled={off} onClick={onCreateReport}>
231
- <ReportIcon size={18} /> Report
232
  </button>
233
  <button disabled={off} onClick={onCreateInfographic}>
234
- <InfographicIcon size={18} /> Infographic
235
  </button>
236
  <button disabled={off} onClick={onCreateQuiz}>
237
- <QuizIcon size={18} /> Quiz
238
  </button>
239
  <button className="nbv-make-wide" disabled={off} onClick={onCreateCards}>
240
- <FlashcardsIcon size={18} /> Flashcards
241
  </button>
242
  </div>
243
  {disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
@@ -274,19 +219,17 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
274
  </span>
275
  </div>
276
  ))}
277
- {others?.map((a) => (
 
 
 
 
278
  <ArtifactCard
279
  key={a.id}
280
  artifact={a}
281
  onCite={cite}
282
  onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))}
283
- actions={(art) => canPost(art) ? <PostButton artifactId={art.id} /> :
284
- art.type === "audio_overview" && art.status === "ready" ? (
285
- <button className="btn btn-small" onClick={() => make({ type: "video", style: "audiogram", audio_artifact_id: art.id })}>
286
- Audiogram
287
- </button>
288
- ) : null
289
- }
290
  />
291
  ))}
292
  </div>}
@@ -297,6 +240,10 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
297
  error={null} onClose={onReportClose} onCreate={makeReport} />}
298
  {infographicOpen && <InfographicDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
299
  busy={false} error={null} onClose={onInfographicClose} onCreate={makeInfographic} />}
 
 
 
 
300
  {cardsOpen && <FlashcardsDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId} busy={false}
301
  error={null} onClose={onCardsClose} onCreate={makeCards} />}
302
  {mapDialog && <MindMapDialog docs={docs} busy={false} error={null} onClose={() => setMapDialog(false)} onCreate={makeMap} />}
@@ -319,6 +266,8 @@ export default function NotebookView() {
319
  const [makingQuiz, setMakingQuiz] = useState(false); // the quiz settings are open in a modal
320
  const [makingInfographic, setMakingInfographic] = useState(false); // the infographic settings are open in a modal
321
  const [makingCards, setMakingCards] = useState(false); // the flashcard settings are open in a modal
 
 
322
  const [makingReport, setMakingReport] = useState(false); // the report dialog is open in a modal
323
  const [toAdd, setToAdd] = useState<string[]>([]);
324
  const [addError, setAddError] = useState<string | null>(null);
@@ -335,6 +284,8 @@ export default function NotebookView() {
335
  const chatLoad = useRef(0);
336
  const asked = useRef(false);
337
  const bottom = useRef<HTMLDivElement>(null);
 
 
338
 
339
  // Auto-save the title shortly after the user stops typing.
340
  useEffect(() => {
@@ -527,6 +478,16 @@ export default function NotebookView() {
527
  setMakingInfographic(true);
528
  };
529
 
 
 
 
 
 
 
 
 
 
 
530
  const openCards = () => {
531
  seedDocs(nb.id, nb.documents);
532
  setMakingCards(true);
@@ -544,7 +505,9 @@ export default function NotebookView() {
544
  };
545
 
546
  return (
547
- <div className={`nbv${historyOpen ? "" : " nbv-history-closed"}`}>
 
 
548
  {historyOpen && <aside className="nbv-pane nbv-history" aria-label="Notebook chats">
549
  <Link to="/app/notebooks" className="nbv-history-back">
550
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -753,6 +716,8 @@ export default function NotebookView() {
753
  onQuizClose={() => setMakingQuiz(false)} onCreateQuiz={openQuiz}
754
  reportOpen={makingReport} onReportClose={() => setMakingReport(false)} onCreateReport={openReport}
755
  infographicOpen={makingInfographic} onInfographicClose={() => setMakingInfographic(false)} onCreateInfographic={openInfographic}
 
 
756
  cardsOpen={makingCards} onCardsClose={() => setMakingCards(false)} onCreateCards={openCards}
757
  disabled={nb.documents.length === 0}
758
  onCreateVideo={() => {
 
1
  import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
2
  import { Link, useParams, useSearchParams } from "react-router-dom";
3
+ import { artifactsApi, notebooksApi } from "../api/endpoints";
4
  import { streamSSE } from "../api/stream";
5
+ import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook } from "../api/types";
6
  import { AnswerFeedback } from "../components/AnswerFeedback";
7
  import { ArtifactCard, CitationList } from "../components/ArtifactCard";
8
  import { Markdown } from "../components/Markdown";
9
  import { DocPicker } from "../components/DocPicker";
10
+ import { FlashcardsIcon, HeadphonesIcon, InfographicIcon, QuizIcon, ReportIcon, SlidesIcon, TelescopeIcon, VideoIcon } from "../components/icons/Icons";
 
11
  import { MindMapDialog, MindMapExplorer, MindMapRow } from "../components/MindMap";
12
  import { FlashcardsDialog, type FlashcardsRequest } from "../components/Flashcards";
13
  import { InfographicDialog, type InfographicRequest } from "../components/Infographic";
14
+ import { SlideDeckDialog, SlideDeckRow, type SlideDeckRequest } from "../components/SlideDeck";
15
+ import { AudioOverviewCard, AudioOverviewDialog, prefetchVoices, type AudioOverviewRequest } from "../components/AudioOverview";
16
  import { QuizDialog, type QuizRequest } from "../components/Quiz";
17
  import { MAX_STUDY_POSTS, prefetchFocus } from "../components/SourceFocusFields";
18
  import { ReportDialog, type ReportRequest } from "../components/ReportDialog";
19
+ import { PaneResizer, usePaneWidths } from "../components/PaneResizer";
20
  import { Reader } from "../components/Reader";
21
  import { seedDocs } from "../components/video/sourceCache";
22
  import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
 
47
  }
48
 
49
  /** Create: one click per format with sensible defaults, options folded away; then what was made from this notebook. */
50
+ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, disabled, quizOpen, onQuizClose, onCreateQuiz, cardsOpen, onCardsClose, onCreateCards, infographicOpen, onInfographicClose, onCreateInfographic, audioOpen, onAudioClose, onCreateAudio, slidesOpen, onSlidesClose, onCreateSlides, reportOpen, onReportClose, onCreateReport, onCreateVideo }: {
51
  notebookId: string;
52
  notebookTitle: string;
53
  docs: { id: string; title: string; locked?: boolean }[];
 
63
  infographicOpen: boolean;
64
  onInfographicClose: () => void;
65
  onCreateInfographic: () => void;
66
+ audioOpen: boolean;
67
+ onAudioClose: () => void;
68
+ onCreateAudio: () => void;
69
+ slidesOpen: boolean;
70
+ onSlidesClose: () => void;
71
+ onCreateSlides: () => void;
72
  reportOpen: boolean;
73
  onReportClose: () => void;
74
  onCreateReport: () => void;
75
  onCreateVideo: () => void;
76
  }) {
77
  const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
 
 
78
  const [error, setError] = useState<string | null>(null);
 
79
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
 
 
 
 
80
  const [mapDialog, setMapDialog] = useState(false);
81
  const [waitingMap, setWaitingMap] = useState<string | null>(null); // a map the user just asked for: open it when ready
82
  const [exploring, setExploring] = useState<Artifact | null>(null);
83
 
84
  const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  useEffect(() => {
87
  load();
88
  }, [load]);
89
 
90
+ // The voices for the audio overview settings take a few seconds to list: ask now so they are ready when it opens.
91
+ useEffect(() => {
92
+ prefetchVoices().catch(() => undefined);
93
+ }, []);
94
+
95
  // The focus suggestions the study dialogs show, made now so they are waiting when a dialog opens.
96
  const startIds = docs.filter((d) => !d.locked).slice(0, MAX_STUDY_POSTS).map((d) => d.id).join(",");
97
  useEffect(() => {
98
  if (startIds) prefetchFocus(notebookId, startIds.split(","));
99
  }, [notebookId, startIds]);
100
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
  // Every "make" from a dialog works the same way: the dialog closes at once, a card in the panel says it is starting, the
103
  // artifact replaces it as soon as the server has it (and shows its own progress), and a failure is shown in the panel.
 
132
  launch("report", onReportClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "report" }));
133
  const makeInfographic = (body: InfographicRequest) =>
134
  launch("infographic", onInfographicClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "infographic" }));
135
+ const makeAudio = (body: AudioOverviewRequest) =>
136
+ launch("audio overview", onAudioClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "audio_overview" }));
137
+ const makeSlides = (body: SlideDeckRequest) =>
138
+ launch("slide deck", onSlidesClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "slide_deck" }));
139
  const makeCards = (body: FlashcardsRequest) =>
140
  launch("flashcards", onCardsClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "flashcards" }));
141
 
 
143
  const others = artifacts?.filter((a) => a.type !== "mind_map");
144
 
145
  const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });
146
+ const off = disabled;
147
 
148
  return (
149
  <div className="stack">
 
163
  <circle cx="58" cy="18" r="2" fill="currentColor" />
164
  </svg>
165
  </button>
166
+ <button className="nbv-make-audio" disabled={off} onClick={onCreateAudio}>
167
+ <HeadphonesIcon size={18} /> <span className="nbv-make-text">Audio overview</span>
168
+ </button>
169
+ <button className="nbv-make-slides" disabled={off} onClick={onCreateSlides}>
170
+ <SlidesIcon size={18} /> <span className="nbv-make-text">Slide deck</span>
 
 
171
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  <button disabled={off} onClick={onCreateVideo}>
173
+ <VideoIcon size={18} /> <span className="nbv-make-text">Video</span>
174
  </button>
175
  <button disabled={off} onClick={onCreateReport}>
176
+ <ReportIcon size={18} /> <span className="nbv-make-text">Report</span>
177
  </button>
178
  <button disabled={off} onClick={onCreateInfographic}>
179
+ <InfographicIcon size={18} /> <span className="nbv-make-text">Infographic</span>
180
  </button>
181
  <button disabled={off} onClick={onCreateQuiz}>
182
+ <QuizIcon size={18} /> <span className="nbv-make-text">Quiz</span>
183
  </button>
184
  <button className="nbv-make-wide" disabled={off} onClick={onCreateCards}>
185
+ <FlashcardsIcon size={18} /> <span className="nbv-make-text">Flashcards</span>
186
  </button>
187
  </div>
188
  {disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
 
219
  </span>
220
  </div>
221
  ))}
222
+ {others?.map((a) => a.type === "slide_deck" ? (
223
+ <SlideDeckRow key={a.id} artifact={a} onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} />
224
+ ) : a.type === "audio_overview" ? (
225
+ <AudioOverviewCard key={a.id} artifact={a} onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} />
226
+ ) : (
227
  <ArtifactCard
228
  key={a.id}
229
  artifact={a}
230
  onCite={cite}
231
  onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))}
232
+ actions={(art) => canPost(art) ? <PostButton artifactId={art.id} /> : null}
 
 
 
 
 
 
233
  />
234
  ))}
235
  </div>}
 
240
  error={null} onClose={onReportClose} onCreate={makeReport} />}
241
  {infographicOpen && <InfographicDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
242
  busy={false} error={null} onClose={onInfographicClose} onCreate={makeInfographic} />}
243
+ {audioOpen && <AudioOverviewDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
244
+ onClose={onAudioClose} onCreate={makeAudio} />}
245
+ {slidesOpen && <SlideDeckDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
246
+ onClose={onSlidesClose} onCreate={makeSlides} />}
247
  {cardsOpen && <FlashcardsDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId} busy={false}
248
  error={null} onClose={onCardsClose} onCreate={makeCards} />}
249
  {mapDialog && <MindMapDialog docs={docs} busy={false} error={null} onClose={() => setMapDialog(false)} onCreate={makeMap} />}
 
266
  const [makingQuiz, setMakingQuiz] = useState(false); // the quiz settings are open in a modal
267
  const [makingInfographic, setMakingInfographic] = useState(false); // the infographic settings are open in a modal
268
  const [makingCards, setMakingCards] = useState(false); // the flashcard settings are open in a modal
269
+ const [makingSlides, setMakingSlides] = useState(false); // the slide deck settings are open in a modal
270
+ const [makingAudio, setMakingAudio] = useState(false); // the audio overview settings are open in a modal
271
  const [makingReport, setMakingReport] = useState(false); // the report dialog is open in a modal
272
  const [toAdd, setToAdd] = useState<string[]>([]);
273
  const [addError, setAddError] = useState<string | null>(null);
 
284
  const chatLoad = useRef(0);
285
  const asked = useRef(false);
286
  const bottom = useRef<HTMLDivElement>(null);
287
+ const layout = useRef<HTMLDivElement>(null);
288
+ const panes = usePaneWidths(layout, { leftOpen: historyOpen }); // the side panels' dragged widths
289
 
290
  // Auto-save the title shortly after the user stops typing.
291
  useEffect(() => {
 
478
  setMakingInfographic(true);
479
  };
480
 
481
+ const openAudio = () => {
482
+ seedDocs(nb.id, nb.documents);
483
+ setMakingAudio(true);
484
+ };
485
+
486
+ const openSlides = () => {
487
+ seedDocs(nb.id, nb.documents);
488
+ setMakingSlides(true);
489
+ };
490
+
491
  const openCards = () => {
492
  seedDocs(nb.id, nb.documents);
493
  setMakingCards(true);
 
505
  };
506
 
507
  return (
508
+ <div ref={layout} className={`nbv${historyOpen ? "" : " nbv-history-closed"}`} style={panes.style}>
509
+ <PaneResizer side="left" panes={panes} container={layout} label="Resize the chats panel" />
510
+ <PaneResizer side="right" panes={panes} container={layout} label="Resize the create panel" />
511
  {historyOpen && <aside className="nbv-pane nbv-history" aria-label="Notebook chats">
512
  <Link to="/app/notebooks" className="nbv-history-back">
513
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
 
716
  onQuizClose={() => setMakingQuiz(false)} onCreateQuiz={openQuiz}
717
  reportOpen={makingReport} onReportClose={() => setMakingReport(false)} onCreateReport={openReport}
718
  infographicOpen={makingInfographic} onInfographicClose={() => setMakingInfographic(false)} onCreateInfographic={openInfographic}
719
+ audioOpen={makingAudio} onAudioClose={() => setMakingAudio(false)} onCreateAudio={openAudio}
720
+ slidesOpen={makingSlides} onSlidesClose={() => setMakingSlides(false)} onCreateSlides={openSlides}
721
  cardsOpen={makingCards} onCardsClose={() => setMakingCards(false)} onCreateCards={openCards}
722
  disabled={nb.documents.length === 0}
723
  onCreateVideo={() => {
frontend/src/pages/VideoCreate.tsx CHANGED
@@ -465,7 +465,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
465
  {voices === null ? (
466
  <Loading label="Loading voices" />
467
  ) : saved.length === 0 ? (
468
- <p className="muted">No voices yet. Add some on the <Link to="/app/archive?tab=voices">Voices</Link> tab of your Library.</p>
469
  ) : (
470
  <div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
471
  {saved.map((v) => {
 
465
  {voices === null ? (
466
  <Loading label="Loading voices" />
467
  ) : saved.length === 0 ? (
468
+ <p className="muted">No voices yet. Add some in <Link to="/app/voices">Manage voices</Link>.</p>
469
  ) : (
470
  <div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
471
  {saved.map((v) => {
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -1,21 +1,11 @@
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { useSearchParams } from "react-router-dom";
3
  import { voiceApi } from "../api/endpoints";
4
- import type { Job, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
5
- import { DocPicker } from "../components/DocPicker";
6
  import { YourVoices } from "../components/voice/YourVoices";
7
- import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, Tabs } from "../components/ui";
8
  import { useJob } from "../hooks/useJob";
9
  import { useUpgrade } from "../hooks/useUpgrade";
10
 
11
- const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
12
- { key: "tone", label: "Tone", hint: "warm, wry, direct" },
13
- { key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
14
- { key: "structure_habits", label: "Structure habits", hint: "short paragraphs, one idea each" },
15
- { key: "openings", label: "How pieces open", hint: "a number, a small scene" },
16
- { key: "avoid", label: "Never does", hint: "jargon, hashtags" },
17
- ];
18
-
19
  /** Plays one URL at a time across the page. */
20
  function usePlayer() {
21
  const audio = useRef<HTMLAudioElement | null>(null);
@@ -416,216 +406,79 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
416
  );
417
  }
418
 
419
- // The page: two steps. The writing voice builds itself, then the writer picks the two hosts.
420
- // Cloning lives in the "+ Add a voice" menu; a clone's status shows under the voice cards.
421
-
422
- type Step = "writing" | "hosts";
423
-
424
- /** The underlined link between the two views (Speaking voices <-> Writing voice), its arrow pointing the way. */
425
- function ViewLink({ to, label, onGo }: { to: Step; label: string; onGo: (s: Step) => void }) {
426
- const back = to === "hosts";
427
- return (
428
- <button type="button" className={`vp-switch${back ? " back" : ""}`} onClick={() => onGo(to)}>
429
- {back && <span aria-hidden="true">←</span>}
430
- <span className="vp-switch-text">{label}</span>
431
- {!back && <span aria-hidden="true">→</span>}
432
- </button>
433
- );
434
- }
435
 
436
  export default function VoiceProfile() {
437
  const [state, setState] = useState<VoiceState | null>(null);
438
- const [draft, setDraft] = useState<VoiceProfileData | null>(null);
439
- const [params] = useSearchParams();
440
- // Opens on Speaking voices; ?step=writing opens the writing voice (links between the two switch views).
441
- const [step, setStep] = useState<Step>(params.get("step") === "writing" ? "writing" : "hosts");
442
- const [picking, setPicking] = useState(false);
443
- const [samples, setSamples] = useState<string[]>([]);
444
- const [buildJob, setBuildJob] = useState<Job | null>(null);
445
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
446
  const [cloning, setCloning] = useState(false);
447
  const [adding, setAdding] = useState(false); // the Add voices modal
448
  const [voicesKey, setVoicesKey] = useState(0); // bumped when a voice was made, so Your voices reloads
449
  const { openUpgrade } = useUpgrade();
450
- const [saved, setSaved] = useState(false);
451
  const [error, setError] = useState<string | null>(null);
452
- const autoBuilt = useRef(false);
453
  const { play, playing } = usePlayer();
454
 
455
- const load = () =>
456
- voiceApi.get().then((s) => {
457
- setState(s);
458
- setDraft(s.profile);
459
- setSamples(s.sample_doc_ids);
460
- return s;
461
- });
462
  const loadVoices = () => setVoicesKey((k) => k + 1);
463
 
464
- const startBuild = async (ids: string[]) => {
465
- setPicking(false);
466
- setError(null);
467
- try {
468
- setBuildJob(await voiceApi.build(ids));
469
- } catch (e) {
470
- setError(errorMessage(e));
471
- }
472
- };
473
-
474
  useEffect(() => {
475
- // No writing voice yet: build one from the longest posts straight away instead of asking.
476
- load().then((s) => {
477
- if (!s.profile && !autoBuilt.current) {
478
- autoBuilt.current = true;
479
- void startBuild([]);
480
- }
481
- });
482
  // eslint-disable-next-line react-hooks/exhaustive-deps
483
  }, []);
484
 
485
- const build = useJob(buildJob, () => load());
486
  const clone = useJob(cloneJob, () => {
487
  load();
488
  loadVoices(); // a finished clone is saved to Your voices as "My voice"
489
  });
490
 
491
  if (!state) return <div className="loading-center"><Loading label="Loading your voice" /></div>;
492
- const building = build && (build.status === "queued" || build.status === "running");
493
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
494
  const showClone = state.clone.status !== "none" || Boolean(clone);
495
 
496
- const saveProfile = async () => {
497
- setError(null);
498
- try {
499
- setState(await voiceApi.update({ profile: draft! }));
500
- setSaved(true);
501
- setTimeout(() => setSaved(false), 1500);
502
- } catch (e) {
503
- setError(errorMessage(e));
504
- }
505
- };
506
-
507
  return (
508
- // The Library's Voices tab: speaking voices, and the writing voice behind a link.
509
  <div className="stack vp">
510
-
511
- <div className="vp-switch-row">
512
- {step === "hosts"
513
- ? <ViewLink to="writing" label="Writing voice" onGo={setStep} />
514
- : <ViewLink to="hosts" label="Speaking voices" onGo={setStep} />}
515
- </div>
516
  {error && <p className="error-text">{error}</p>}
517
 
518
- {step === "writing" && (
519
- <section className="card stack vp-card">
520
- <h2>How you write</h2>
521
- <p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
522
- {build && (building || build.status === "failed") && <JobProgress job={build} />}
523
- {!draft && !building && (
 
 
 
 
 
524
  <div className="row">
525
- <button className="btn btn-primary" onClick={() => startBuild([])}>
526
- Build my writing voice
527
- </button>
528
- <button className="link-btn small" onClick={() => setPicking(true)}>
529
- or pick the posts yourself
530
- </button>
531
- </div>
532
- )}
533
-
534
- {draft && (
535
- <>
536
- <label className="field">
537
- <span>In a sentence, you sound like this</span>
538
- <textarea className="textarea" rows={7} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
539
- </label>
540
- <details className="delivery">
541
- <summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
542
- <label className="field">
543
- <span>Sentence length</span>
544
- <select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
545
- {["short", "medium", "long", "varied"].map((v) => (
546
- <option key={v}>{v}</option>
547
- ))}
548
- </select>
549
- </label>
550
- {LIST_FIELDS.map((f) => (
551
- <label key={f.key} className="field">
552
- <span>{f.label}</span>
553
- <input
554
- className="input input-sm"
555
- placeholder={f.hint}
556
- value={(draft[f.key] as string[]).join(", ")}
557
- onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
558
- />
559
- </label>
560
- ))}
561
- </details>
562
- <p className="mono muted small">
563
- Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts ·{" "}
564
- <button className="link-btn small" disabled={Boolean(building)} onClick={() => setPicking(true)}>
565
- Rebuild from other posts
566
  </button>
567
- </p>
568
- <div className="row end">
569
- <button className="btn btn-small" onClick={saveProfile}>
570
- {saved ? "Saved" : "Save changes"}
571
  </button>
572
- </div>
573
- </>
574
- )}
575
- </section>
576
- )}
577
-
578
- {step === "hosts" && (
579
- <section className="card stack vp-card">
580
- <h2>Who reads your work aloud</h2>
581
- {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
582
- <YourVoices reloadKey={voicesKey} onAdd={() => setAdding(true)} />
583
-
584
- {showClone && (
585
- <div className="vp-clone">
586
- <strong>Your cloned voice</strong>
587
- {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
588
- {state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It joins your voices when ready.</p>}
589
- {state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
590
- <div className="row">
591
- {state.clone.status === "ready" && state.clone.preview_url && (
592
- <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
593
- {playing === "clone" ? "Stop" : "Hear my voice"}
594
- </button>
595
- )}
596
- {!cloneRunning && state.clone.allowed && (
597
- <button className="btn btn-small" onClick={() => setCloning(true)}>
598
- {state.clone.status === "failed" ? "Try again" : "Record again"}
599
- </button>
600
- )}
601
- {state.clone.status !== "none" && (
602
- <ConfirmButton
603
- confirmLabel="Revoke and delete my voice?"
604
- onConfirm={async () => {
605
- setState(await voiceApi.revoke());
606
- loadVoices();
607
- }}
608
- >
609
- Revoke consent
610
- </ConfirmButton>
611
- )}
612
- </div>
613
  </div>
614
- )}
615
- </section>
616
- )}
617
-
618
- {picking && (
619
- <Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
620
- <DocPicker selected={samples} onChange={setSamples} max={10} />
621
- <p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
622
- <div className="row end">
623
- <button className="btn btn-primary" onClick={() => startBuild(samples)}>
624
- Build profile
625
- </button>
626
  </div>
627
- </Modal>
628
- )}
 
629
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
630
  {adding && (
631
  <Modal title="Add voices" onClose={() => setAdding(false)} wide>
 
1
  import { useCallback, useEffect, useRef, useState } from "react";
 
2
  import { voiceApi } from "../api/endpoints";
3
+ import type { Job, VoiceDesignInput, VoiceDesignPreview, VoiceState } from "../api/types";
 
4
  import { YourVoices } from "../components/voice/YourVoices";
5
+ import { ConfirmButton, errorMessage, JobProgress, Loading, Modal, Tabs } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
7
  import { useUpgrade } from "../hooks/useUpgrade";
8
 
 
 
 
 
 
 
 
 
9
  /** Plays one URL at a time across the page. */
10
  function usePlayer() {
11
  const audio = useRef<HTMLAudioElement | null>(null);
 
406
  );
407
  }
408
 
409
+ // The page: the speaking voices (who reads audio and video aloud). Cloning lives in the "+ Add a voice" menu; a
410
+ // clone's status shows under the voice cards. The writing voice is built by the server on import and not shown here.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
  export default function VoiceProfile() {
413
  const [state, setState] = useState<VoiceState | null>(null);
 
 
 
 
 
 
 
414
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
415
  const [cloning, setCloning] = useState(false);
416
  const [adding, setAdding] = useState(false); // the Add voices modal
417
  const [voicesKey, setVoicesKey] = useState(0); // bumped when a voice was made, so Your voices reloads
418
  const { openUpgrade } = useUpgrade();
 
419
  const [error, setError] = useState<string | null>(null);
 
420
  const { play, playing } = usePlayer();
421
 
422
+ const load = () => voiceApi.get().then(setState, (e) => setError(errorMessage(e)));
 
 
 
 
 
 
423
  const loadVoices = () => setVoicesKey((k) => k + 1);
424
 
 
 
 
 
 
 
 
 
 
 
425
  useEffect(() => {
426
+ void load();
 
 
 
 
 
 
427
  // eslint-disable-next-line react-hooks/exhaustive-deps
428
  }, []);
429
 
 
430
  const clone = useJob(cloneJob, () => {
431
  load();
432
  loadVoices(); // a finished clone is saved to Your voices as "My voice"
433
  });
434
 
435
  if (!state) return <div className="loading-center"><Loading label="Loading your voice" /></div>;
 
436
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
437
  const showClone = state.clone.status !== "none" || Boolean(clone);
438
 
 
 
 
 
 
 
 
 
 
 
 
439
  return (
440
+ // Manage voices (/app/voices): the speaking voices.
441
  <div className="stack vp">
 
 
 
 
 
 
442
  {error && <p className="error-text">{error}</p>}
443
 
444
+ <section className="card stack vp-card">
445
+ <h2>Who reads your work aloud</h2>
446
+ {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
447
+ <YourVoices reloadKey={voicesKey} onAdd={() => setAdding(true)} />
448
+
449
+ {showClone && (
450
+ <div className="vp-clone">
451
+ <strong>Your cloned voice</strong>
452
+ {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
453
+ {state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It joins your voices when ready.</p>}
454
+ {state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
455
  <div className="row">
456
+ {state.clone.status === "ready" && state.clone.preview_url && (
457
+ <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
458
+ {playing === "clone" ? "Stop" : "Hear my voice"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  </button>
460
+ )}
461
+ {!cloneRunning && state.clone.allowed && (
462
+ <button className="btn btn-small" onClick={() => setCloning(true)}>
463
+ {state.clone.status === "failed" ? "Try again" : "Record again"}
464
  </button>
465
+ )}
466
+ {state.clone.status !== "none" && (
467
+ <ConfirmButton
468
+ confirmLabel="Revoke and delete my voice?"
469
+ onConfirm={async () => {
470
+ setState(await voiceApi.revoke());
471
+ loadVoices();
472
+ }}
473
+ >
474
+ Revoke consent
475
+ </ConfirmButton>
476
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
478
  </div>
479
+ )}
480
+ </section>
481
+
482
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
483
  {adding && (
484
  <Modal title="Add voices" onClose={() => setAdding(false)} wide>
frontend/src/pages/Voices.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PageHeader } from "../components/ui";
2
+ import VoiceProfile from "./VoiceProfile";
3
+
4
+ /** Manage voices (in the sidebar): the speaking voices for audio and video, and the writing voice. */
5
+ export default function Voices() {
6
+ return (
7
+ <div className="page-wrap">
8
+ <PageHeader eyebrow="Voices" title="Manage voices" />
9
+ <VoiceProfile />
10
+ </div>
11
+ );
12
+ }
frontend/src/styles/pages.css CHANGED
@@ -2277,54 +2277,6 @@ select.input option {
2277
  animation: welcome-in 0.35s ease both;
2278
  }
2279
 
2280
- /* The link between the two views (Speaking voices / Writing voice): underlined text and an arrow */
2281
- .vp-switch-row {
2282
- display: flex;
2283
- justify-content: flex-end; /* at the right end */
2284
- margin: -6px 0 4px;
2285
- }
2286
-
2287
- .vp-switch {
2288
- display: inline-flex;
2289
- align-items: center;
2290
- gap: 8px;
2291
- padding: 4px 0;
2292
- border: 0;
2293
- background: none;
2294
- color: var(--text-2);
2295
- font: inherit;
2296
- font-size: 0.95rem;
2297
- cursor: pointer;
2298
- transition: color 0.15s;
2299
- }
2300
-
2301
- .vp-switch-text {
2302
- text-decoration: underline;
2303
- text-decoration-color: var(--line-strong);
2304
- text-underline-offset: 4px;
2305
- transition: text-decoration-color 0.15s;
2306
- }
2307
-
2308
- .vp-switch:hover {
2309
- color: var(--text);
2310
- }
2311
-
2312
- .vp-switch:hover .vp-switch-text {
2313
- text-decoration-color: var(--blue);
2314
- }
2315
-
2316
- .vp-switch > span[aria-hidden] {
2317
- transition: transform 0.15s;
2318
- }
2319
-
2320
- .vp-switch:hover > span[aria-hidden] {
2321
- transform: translateX(3px);
2322
- }
2323
-
2324
- .vp-switch.back:hover > span[aria-hidden] {
2325
- transform: translateX(-3px);
2326
- }
2327
-
2328
  .voice-grid {
2329
  list-style: none;
2330
  margin: 0;
@@ -3415,7 +3367,8 @@ select.input,
3415
  }
3416
 
3417
  .nbv {
3418
- grid-template-columns: clamp(16rem, 20vw, 20rem) minmax(0, 1fr);
 
3419
  grid-template-areas:
3420
  "history chat"
3421
  "history side";
@@ -3483,12 +3436,12 @@ select.input,
3483
  }
3484
 
3485
  .nbv {
3486
- grid-template-columns: clamp(16rem, 20vw, 20rem) minmax(0, 1fr) clamp(18rem, 24vw, 24rem);
3487
  grid-template-areas: "history chat side";
3488
  }
3489
 
3490
  .nbv.nbv-history-closed {
3491
- grid-template-columns: minmax(0, 1fr) clamp(18rem, 24vw, 24rem);
3492
  grid-template-areas: "chat side";
3493
  }
3494
 
@@ -4402,6 +4355,87 @@ select.input,
4402
  color: var(--white);
4403
  }
4404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4405
  .lp-pick-go {
4406
  display: inline-flex;
4407
  flex: none;
@@ -4612,52 +4646,6 @@ details.map-legend[open] summary {
4612
  border-color: var(--blue);
4613
  }
4614
 
4615
- .nbv-audio-opts {
4616
- display: grid;
4617
- gap: 12px;
4618
- padding: 14px;
4619
- border: 1px solid var(--blue-35);
4620
- border-radius: var(--radius-sm);
4621
- background: var(--blue-05);
4622
- }
4623
-
4624
- .nbv-audio-row {
4625
- display: grid;
4626
- grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); /* Format needs a little more room than Length */
4627
- gap: 10px;
4628
- }
4629
-
4630
- /* Each dropdown fills its column and can shrink, instead of spilling into its neighbour */
4631
- .nbv-audio-row > .field {
4632
- min-width: 0;
4633
- }
4634
-
4635
- .nbv-audio-row > .field > * {
4636
- width: 100%;
4637
- min-width: 0;
4638
- }
4639
-
4640
- .nbv-audio-opts .dropdown-btn {
4641
- padding: 0 12px;
4642
- font-size: 0.9rem;
4643
- }
4644
-
4645
- .nbv-audio-opts .dropdown-btn {
4646
- height: 40px;
4647
- border-radius: 12px;
4648
- }
4649
-
4650
- .nbv-audio-opts > .btn {
4651
- justify-self: end;
4652
- }
4653
-
4654
- .nbv-audio-opts .dropdown-value {
4655
- min-width: 0;
4656
- overflow: hidden;
4657
- text-overflow: ellipsis;
4658
- white-space: nowrap;
4659
- }
4660
-
4661
  /* ---------- Mind map: the right-rail tile and list, the dialog, and the galaxy explorer ---------- */
4662
  .nbv-make .nbv-make-map {
4663
  grid-column: 1 / -1;
@@ -7559,13 +7547,12 @@ details.map-legend[open] summary {
7559
  border-top-color: var(--black);
7560
  }
7561
 
7562
- /* Create buttons: two to a row. Audio overview (with its options) and Mind Constellation take a whole row. */
 
7563
  .nbv-make {
7564
  grid-template-columns: repeat(2, minmax(0, 1fr));
7565
  }
7566
 
7567
- .nbv-make .nbv-make-main,
7568
- .nbv-make .nbv-audio-opts,
7569
  .nbv-make .nbv-make-stars {
7570
  grid-column: 1 / -1;
7571
  }
@@ -8221,3 +8208,1074 @@ details.map-legend[open] summary {
8221
  .nbv-starting-text strong {
8222
  font: 500 0.92rem var(--font-body);
8223
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2277
  animation: welcome-in 0.35s ease both;
2278
  }
2279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2280
  .voice-grid {
2281
  list-style: none;
2282
  margin: 0;
 
3367
  }
3368
 
3369
  .nbv {
3370
+ position: relative; /* the resize handles are placed over the boundaries */
3371
+ grid-template-columns: var(--nbv-left, clamp(16rem, 20vw, 20rem)) minmax(0, 1fr);
3372
  grid-template-areas:
3373
  "history chat"
3374
  "history side";
 
3436
  }
3437
 
3438
  .nbv {
3439
+ grid-template-columns: var(--nbv-left, clamp(16rem, 20vw, 20rem)) minmax(0, 1fr) var(--nbv-right, clamp(18rem, 24vw, 24rem));
3440
  grid-template-areas: "history chat side";
3441
  }
3442
 
3443
  .nbv.nbv-history-closed {
3444
+ grid-template-columns: minmax(0, 1fr) var(--nbv-right, clamp(18rem, 24vw, 24rem));
3445
  grid-template-areas: "chat side";
3446
  }
3447
 
 
4355
  color: var(--white);
4356
  }
4357
 
4358
+ /* Under the calendar: how many posts are coming up, and how to change one */
4359
+ .lp-cal-foot {
4360
+ display: flex;
4361
+ flex-wrap: wrap;
4362
+ align-items: baseline;
4363
+ justify-content: space-between;
4364
+ gap: 6px 16px;
4365
+ }
4366
+
4367
+ .lp-cal-foot p {
4368
+ margin: 0;
4369
+ }
4370
+
4371
+ .lp-cal-hint {
4372
+ color: var(--text-2);
4373
+ }
4374
+
4375
+ /* Editing a scheduled post: When and Account side by side, their labels and boxes on one line (the time zone note
4376
+ sits under When without pushing Account down), and the actions on their own line below a rule */
4377
+ .lp-edit-when {
4378
+ display: grid;
4379
+ grid-template-columns: repeat(2, minmax(0, 1fr));
4380
+ align-items: start;
4381
+ gap: 16px;
4382
+ }
4383
+
4384
+ .lp-edit-when .field {
4385
+ margin: 0;
4386
+ }
4387
+
4388
+ .lp-edit-when select.input {
4389
+ width: 100%;
4390
+ }
4391
+
4392
+ @media (max-width: 640px) {
4393
+ .lp-edit-when {
4394
+ grid-template-columns: minmax(0, 1fr);
4395
+ }
4396
+ }
4397
+
4398
+ .lp-edit-actions {
4399
+ flex-wrap: wrap;
4400
+ gap: 12px;
4401
+ margin-top: 4px;
4402
+ padding-top: 16px;
4403
+ border-top: 1px solid var(--line);
4404
+ }
4405
+
4406
+ /* "Audio can't be posted": the headphones, why, and the download */
4407
+ .lp-audio-only {
4408
+ max-width: 460px;
4409
+ }
4410
+
4411
+ .lp-audio-only p {
4412
+ margin: 0;
4413
+ line-height: 1.6;
4414
+ }
4415
+
4416
+ .lp-audio-only-icon {
4417
+ display: grid;
4418
+ place-items: center;
4419
+ width: 52px;
4420
+ height: 52px;
4421
+ border-radius: 50%;
4422
+ background: var(--blue-10);
4423
+ color: var(--blue);
4424
+ }
4425
+
4426
+ .lp-audio-only-actions {
4427
+ justify-content: flex-end;
4428
+ align-items: center;
4429
+ gap: 10px;
4430
+ }
4431
+
4432
+ /* A slide deck: outlined like a video, dashed (its slides go out as images) */
4433
+ .lp-tag.deck {
4434
+ border-color: var(--blue-35);
4435
+ border-style: dashed;
4436
+ color: var(--white);
4437
+ }
4438
+
4439
  .lp-pick-go {
4440
  display: inline-flex;
4441
  flex: none;
 
4646
  border-color: var(--blue);
4647
  }
4648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4649
  /* ---------- Mind map: the right-rail tile and list, the dialog, and the galaxy explorer ---------- */
4650
  .nbv-make .nbv-make-map {
4651
  grid-column: 1 / -1;
 
7547
  border-top-color: var(--black);
7548
  }
7549
 
7550
+ /* Create buttons: two to a row. Audio overview shares its row with Slide deck; its options and Mind Constellation take
7551
+ a whole row. */
7552
  .nbv-make {
7553
  grid-template-columns: repeat(2, minmax(0, 1fr));
7554
  }
7555
 
 
 
7556
  .nbv-make .nbv-make-stars {
7557
  grid-column: 1 / -1;
7558
  }
 
8208
  .nbv-starting-text strong {
8209
  font: 500 0.92rem var(--font-body);
8210
  }
8211
+
8212
+
8213
+ /* ---- Slide decks ------------------------------------------------------------------------------------------------ */
8214
+
8215
+ .sd-dialog {
8216
+ gap: 22px;
8217
+ }
8218
+
8219
+ /* The settings: the two styles across one row, then format, length and language side by side */
8220
+ .sd-themes {
8221
+ display: grid;
8222
+ grid-template-columns: repeat(2, minmax(0, 220px));
8223
+ justify-content: center;
8224
+ gap: 16px;
8225
+ }
8226
+
8227
+ .sd-theme {
8228
+ display: grid;
8229
+ gap: 6px;
8230
+ padding: 5px 5px 8px;
8231
+ text-align: left;
8232
+ border: 1px solid var(--line);
8233
+ border-radius: 12px;
8234
+ background: transparent;
8235
+ color: inherit;
8236
+ cursor: pointer;
8237
+ transition: border-color 0.15s, box-shadow 0.15s;
8238
+ }
8239
+
8240
+ .sd-theme:hover {
8241
+ border-color: var(--line-strong);
8242
+ }
8243
+
8244
+ .sd-theme.on {
8245
+ border-color: var(--blue);
8246
+ box-shadow: 0 0 0 1px var(--blue), var(--glow-sm);
8247
+ }
8248
+
8249
+ .sd-theme-img {
8250
+ position: relative;
8251
+ display: block;
8252
+ }
8253
+
8254
+ .sd-theme-img img {
8255
+ display: block;
8256
+ width: 100%;
8257
+ aspect-ratio: 16 / 9; /* the whole slide, small */
8258
+ object-fit: cover;
8259
+ border-radius: 8px;
8260
+ }
8261
+
8262
+ .sd-check {
8263
+ position: absolute;
8264
+ top: 6px;
8265
+ right: 6px;
8266
+ display: grid;
8267
+ place-items: center;
8268
+ width: 22px;
8269
+ height: 22px;
8270
+ border-radius: 50%;
8271
+ background: var(--black);
8272
+ color: var(--white);
8273
+ box-shadow: 0 0 0 2px var(--blue);
8274
+ }
8275
+
8276
+ .sd-theme-name {
8277
+ padding: 0 4px;
8278
+ text-align: center;
8279
+ font-size: 0.85rem;
8280
+ font-weight: 600;
8281
+ }
8282
+
8283
+ .sd-opts {
8284
+ display: grid;
8285
+ grid-template-columns: auto minmax(210px, 1fr) minmax(140px, 0.7fr); /* format as wide as its pills */
8286
+ column-gap: 28px;
8287
+ row-gap: 14px;
8288
+ align-items: start;
8289
+ }
8290
+
8291
+ .sd-opts .field {
8292
+ gap: 8px;
8293
+ min-width: 0;
8294
+ }
8295
+
8296
+ /* The dropdowns stay inside their column: a long value shortens with an ellipsis */
8297
+ .sd-opts .dropdown,
8298
+ .sd-opts .dropdown-btn {
8299
+ min-width: 0;
8300
+ max-width: 100%;
8301
+ }
8302
+
8303
+ .sd-opts .dropdown-value {
8304
+ overflow: hidden;
8305
+ text-overflow: ellipsis;
8306
+ white-space: nowrap;
8307
+ }
8308
+
8309
+ /* The length's slide range, inside its dropdown: the same small grey text as the hints under the other fields */
8310
+ .sd-opts .dropdown-value .muted {
8311
+ font-size: 0.83em;
8312
+ }
8313
+
8314
+ .sd-opts .dropdown-btn {
8315
+ padding-left: 16px;
8316
+ padding-right: 14px;
8317
+ }
8318
+
8319
+ /* Format pills look exactly like the dropdowns beside them: same height, text, border and fill */
8320
+ .sd-opts .vw-chips {
8321
+ flex-wrap: nowrap;
8322
+ gap: 8px;
8323
+ }
8324
+
8325
+ .sd-opts .vw-chip {
8326
+ display: inline-flex;
8327
+ align-items: center;
8328
+ height: 48px;
8329
+ padding: 0 18px;
8330
+ border: 1px solid var(--line-strong);
8331
+ border-radius: 999px;
8332
+ background: rgba(0, 0, 0, 0.6);
8333
+ color: var(--text);
8334
+ font: 500 1rem var(--font-body);
8335
+ white-space: nowrap;
8336
+ }
8337
+
8338
+ .sd-opts .vw-chip.on {
8339
+ border-color: var(--blue);
8340
+ background: var(--blue);
8341
+ color: var(--white);
8342
+ }
8343
+
8344
+ .sd-opts .vw-chip:hover:not(.on) {
8345
+ border-color: var(--blue-60);
8346
+ }
8347
+
8348
+ /* The format's description wraps under the pills instead of widening their column */
8349
+ .sd-opts > .field:first-child > small {
8350
+ display: block;
8351
+ width: 0;
8352
+ min-width: 100%;
8353
+ }
8354
+
8355
+ /* A little more room than other dialogs, for the three settings side by side */
8356
+ .modal.modal-wide:has(.sd-dialog) {
8357
+ width: min(840px, calc(100vw - 32px));
8358
+ }
8359
+
8360
+ .sd-prompt {
8361
+ width: 100%;
8362
+ min-height: 92px;
8363
+ padding: 14px 18px;
8364
+ border-radius: 14px;
8365
+ line-height: 1.55;
8366
+ resize: vertical;
8367
+ }
8368
+
8369
+ /* One 1920 x 1080 slide, scaled into whatever box it is given. */
8370
+ .sd-frame {
8371
+ position: relative;
8372
+ width: 100%;
8373
+ overflow: hidden;
8374
+ background: #000;
8375
+ }
8376
+
8377
+ .sd-frame-width {
8378
+ aspect-ratio: 16 / 9;
8379
+ }
8380
+
8381
+ .sd-frame-contain {
8382
+ position: absolute;
8383
+ inset: 0;
8384
+ background: transparent;
8385
+ }
8386
+
8387
+ .sd-frame iframe {
8388
+ position: absolute;
8389
+ top: 50%;
8390
+ left: 50%;
8391
+ border: 0;
8392
+ transform-origin: center center;
8393
+ pointer-events: none;
8394
+ }
8395
+
8396
+ /* In edit mode the slide's text is edited in place, so the frame takes the mouse */
8397
+ .sd-frame-edit iframe {
8398
+ pointer-events: auto;
8399
+ }
8400
+
8401
+ /* A deck in the notebook's list: a quiet row (icon, title, "2 sources · 5d ago", menu), highlighted on hover */
8402
+ .sd-item {
8403
+ display: grid;
8404
+ grid-template-columns: minmax(0, 1fr) auto;
8405
+ align-items: center;
8406
+ column-gap: 4px;
8407
+ padding: 4px 4px 4px 0;
8408
+ border-radius: 16px;
8409
+ transition: background 0.15s;
8410
+ }
8411
+
8412
+ .sd-item.is-ready:hover,
8413
+ .sd-item:focus-within {
8414
+ background: var(--surface-2);
8415
+ }
8416
+
8417
+ .sd-item-main {
8418
+ display: grid;
8419
+ grid-template-columns: 40px minmax(0, 1fr);
8420
+ align-items: center;
8421
+ gap: 10px;
8422
+ min-width: 0;
8423
+ padding: 8px 4px 8px 10px;
8424
+ border-radius: 16px;
8425
+ outline: none;
8426
+ }
8427
+
8428
+ .sd-item.is-ready .sd-item-main {
8429
+ cursor: pointer;
8430
+ }
8431
+
8432
+ .sd-item-main:focus-visible {
8433
+ box-shadow: 0 0 0 2px var(--blue);
8434
+ }
8435
+
8436
+ .sd-item-icon {
8437
+ display: grid;
8438
+ place-items: center;
8439
+ width: 40px;
8440
+ height: 40px;
8441
+ color: var(--blue);
8442
+ }
8443
+
8444
+ .sd-item-text {
8445
+ display: grid;
8446
+ gap: 2px;
8447
+ min-width: 0;
8448
+ }
8449
+
8450
+ .sd-item-text strong {
8451
+ overflow: hidden;
8452
+ text-overflow: ellipsis;
8453
+ white-space: nowrap;
8454
+ font-weight: 500;
8455
+ font-size: 0.95rem;
8456
+ color: var(--text);
8457
+ }
8458
+
8459
+ .sd-item-meta {
8460
+ overflow: hidden;
8461
+ text-overflow: ellipsis;
8462
+ white-space: nowrap;
8463
+ font-size: 0.82rem;
8464
+ color: var(--text-3);
8465
+ }
8466
+
8467
+ .sd-item .sd-menu-wrap .sd-icon-btn {
8468
+ color: var(--text-2);
8469
+ }
8470
+
8471
+ .sd-item-progress {
8472
+ grid-column: 1 / -1;
8473
+ padding: 0 12px 8px 60px;
8474
+ }
8475
+
8476
+ .sd-item-failed {
8477
+ display: flex;
8478
+ align-items: center;
8479
+ gap: 12px;
8480
+ flex-wrap: wrap;
8481
+ }
8482
+
8483
+ .sd-item-failed p {
8484
+ flex: 1;
8485
+ margin: 0;
8486
+ }
8487
+
8488
+ .sd-rename {
8489
+ width: 100%;
8490
+ padding: 4px 10px;
8491
+ font-size: 0.95rem;
8492
+ }
8493
+
8494
+ /* The deck's popup: actions along the top, every slide below it */
8495
+ .modal.modal-wide:has(.sd-view) {
8496
+ width: min(980px, calc(100vw - 32px));
8497
+ max-height: calc(100vh - 32px);
8498
+ display: flex;
8499
+ flex-direction: column;
8500
+ transition: width 0.2s ease;
8501
+ }
8502
+
8503
+ .modal.modal-wide:has(.sd-view.big) {
8504
+ width: calc(100vw - 32px);
8505
+ height: calc(100vh - 32px);
8506
+ }
8507
+
8508
+ .modal:has(.sd-view) .modal-head {
8509
+ gap: 12px;
8510
+ }
8511
+
8512
+ .modal:has(.sd-view) .modal-head h2 {
8513
+ min-width: 0;
8514
+ overflow: hidden;
8515
+ text-overflow: ellipsis;
8516
+ white-space: nowrap;
8517
+ }
8518
+
8519
+ .sd-head-actions {
8520
+ display: flex;
8521
+ align-items: center;
8522
+ gap: 4px;
8523
+ flex: none;
8524
+ }
8525
+
8526
+ .sd-icon-btn {
8527
+ display: grid;
8528
+ place-items: center;
8529
+ width: 38px;
8530
+ height: 38px;
8531
+ padding: 0;
8532
+ border: 0;
8533
+ border-radius: 50%;
8534
+ background: transparent;
8535
+ color: var(--text);
8536
+ cursor: pointer;
8537
+ transition: background 0.15s, color 0.15s;
8538
+ }
8539
+
8540
+ .sd-icon-btn:hover:not(:disabled),
8541
+ .sd-icon-btn[aria-expanded="true"],
8542
+ .sd-icon-btn[aria-pressed="true"] {
8543
+ background: var(--surface-2);
8544
+ color: var(--white);
8545
+ }
8546
+
8547
+ .sd-icon-btn:focus-visible {
8548
+ outline: 2px solid var(--blue);
8549
+ outline-offset: 1px;
8550
+ }
8551
+
8552
+ .sd-icon-btn:disabled {
8553
+ opacity: 0.5;
8554
+ cursor: default;
8555
+ }
8556
+
8557
+ .sd-menu-wrap {
8558
+ position: relative;
8559
+ }
8560
+
8561
+ /* In <body>, above popups (z-index over the modal's backdrop) */
8562
+ .sd-menu.sd-menu-floating {
8563
+ position: fixed;
8564
+ top: auto;
8565
+ right: auto;
8566
+ z-index: 2100;
8567
+ }
8568
+
8569
+ .sd-menu {
8570
+ position: absolute;
8571
+ top: calc(100% + 6px);
8572
+ right: 0;
8573
+ z-index: 20;
8574
+ display: grid;
8575
+ min-width: 270px;
8576
+ padding: 6px;
8577
+ border: 1px solid var(--line-strong);
8578
+ border-radius: var(--radius-sm);
8579
+ background: var(--black);
8580
+ box-shadow: var(--glow-lg);
8581
+ }
8582
+
8583
+ .sd-menu button {
8584
+ display: flex;
8585
+ align-items: center;
8586
+ gap: 12px;
8587
+ padding: 10px 12px;
8588
+ border: 0;
8589
+ border-radius: 6px;
8590
+ background: transparent;
8591
+ color: var(--white);
8592
+ font: 500 0.92rem var(--font-body);
8593
+ text-align: left;
8594
+ white-space: nowrap;
8595
+ cursor: pointer;
8596
+ }
8597
+
8598
+ .sd-menu button:hover,
8599
+ .sd-menu button:focus-visible {
8600
+ background: var(--surface-2);
8601
+ }
8602
+
8603
+ .sd-menu button.danger {
8604
+ color: #ff8a8a;
8605
+ }
8606
+
8607
+ .sd-confirm p {
8608
+ margin: 0;
8609
+ line-height: 1.6;
8610
+ }
8611
+
8612
+ .sd-confirm-actions {
8613
+ justify-content: flex-end;
8614
+ gap: 10px;
8615
+ }
8616
+
8617
+ .sd-menu .error-text {
8618
+ margin: 4px 8px;
8619
+ }
8620
+
8621
+ .sd-menu-error {
8622
+ position: absolute;
8623
+ top: calc(100% + 6px);
8624
+ right: 0;
8625
+ width: 240px;
8626
+ text-align: right;
8627
+ }
8628
+
8629
+ .sd-view {
8630
+ display: grid;
8631
+ gap: 12px;
8632
+ min-height: 0;
8633
+ flex: 1;
8634
+ }
8635
+
8636
+ .sd-meta {
8637
+ margin: 0;
8638
+ }
8639
+
8640
+ .sd-stack {
8641
+ display: grid;
8642
+ gap: 16px;
8643
+ max-height: calc(100vh - 190px);
8644
+ margin: 0;
8645
+ padding: 0 6px 6px 0;
8646
+ overflow-y: auto;
8647
+ list-style: none;
8648
+ }
8649
+
8650
+ .sd-view.big .sd-stack {
8651
+ max-height: calc(100vh - 160px);
8652
+ justify-items: center;
8653
+ }
8654
+
8655
+ .sd-view.big .sd-stack > li {
8656
+ width: min(100%, calc((100vh - 190px) * 16 / 9));
8657
+ }
8658
+
8659
+ .sd-slide {
8660
+ display: block;
8661
+ width: 100%;
8662
+ padding: 0;
8663
+ border: 1px solid var(--line);
8664
+ border-radius: 14px;
8665
+ overflow: hidden;
8666
+ background: none;
8667
+ cursor: pointer;
8668
+ transition: border-color 0.15s, box-shadow 0.15s;
8669
+ }
8670
+
8671
+ .sd-slide:hover,
8672
+ .sd-slide:focus-visible {
8673
+ border-color: var(--blue-60);
8674
+ box-shadow: var(--glow-sm);
8675
+ }
8676
+
8677
+ /* Edit mode: the slide's text boxes are clicked into, so no pointer; the slide being typed in is lit */
8678
+ .sd-slide-editing {
8679
+ cursor: default;
8680
+ }
8681
+
8682
+ .sd-slide-editing:focus-within {
8683
+ border-color: var(--blue);
8684
+ box-shadow: var(--glow-sm);
8685
+ }
8686
+
8687
+ .sd-save {
8688
+ height: 36px;
8689
+ margin-right: 6px;
8690
+ padding: 0 18px;
8691
+ }
8692
+
8693
+ .sd-edit-hint {
8694
+ color: var(--text-2);
8695
+ }
8696
+
8697
+ .sd-edit-error {
8698
+ margin: -4px 0 10px;
8699
+ }
8700
+
8701
+ /* Edit mode: each slide's controls above it (layout, text boxes to add, move, duplicate, delete) */
8702
+ .sd-tools {
8703
+ display: flex;
8704
+ flex-wrap: wrap;
8705
+ align-items: center;
8706
+ gap: 6px;
8707
+ margin-bottom: 8px;
8708
+ }
8709
+
8710
+ .sd-tools-no {
8711
+ margin-right: 4px;
8712
+ color: var(--text-2);
8713
+ font-size: 0.8125rem;
8714
+ font-weight: 600;
8715
+ }
8716
+
8717
+ .sd-tools-layout select,
8718
+ .sd-add-slide select {
8719
+ width: auto;
8720
+ height: 32px;
8721
+ padding: 0 30px 0 12px;
8722
+ font-size: 0.8125rem;
8723
+ }
8724
+
8725
+ .sd-tool {
8726
+ height: 32px;
8727
+ padding: 0 12px;
8728
+ border: 1px dashed var(--line-strong);
8729
+ border-radius: 999px;
8730
+ background: transparent;
8731
+ color: var(--text);
8732
+ font: 600 0.8125rem var(--font-body);
8733
+ cursor: pointer;
8734
+ }
8735
+
8736
+ .sd-tool:hover:not(:disabled) {
8737
+ border-color: var(--blue);
8738
+ background: var(--surface-2);
8739
+ }
8740
+
8741
+ .sd-tool:disabled {
8742
+ opacity: 0.5;
8743
+ cursor: default;
8744
+ }
8745
+
8746
+ .sd-tools-gap {
8747
+ flex: 1;
8748
+ }
8749
+
8750
+ .sd-tool-icon {
8751
+ width: 32px;
8752
+ height: 32px;
8753
+ }
8754
+
8755
+ .sd-tool-danger:hover:not(:disabled) {
8756
+ color: #ff8a8a;
8757
+ }
8758
+
8759
+ /* Between two slides in edit mode: a divider line with "+ Add slide here" on it */
8760
+ .sd-add-slide {
8761
+ display: flex;
8762
+ align-items: center;
8763
+ gap: 16px;
8764
+ margin: 20px 0 12px;
8765
+ }
8766
+
8767
+ .sd-add-slide::before,
8768
+ .sd-add-slide::after {
8769
+ content: "";
8770
+ flex: 1;
8771
+ height: 1px;
8772
+ background: var(--line-strong);
8773
+ }
8774
+
8775
+ .sd-add-slide select {
8776
+ border-style: dashed;
8777
+ }
8778
+
8779
+ /* The server is drawing the slides again: they wait, not taking clicks */
8780
+ .sd-stack.sd-busy .sd-slide {
8781
+ opacity: 0.6;
8782
+ pointer-events: none;
8783
+ transition: opacity 0.15s;
8784
+ }
8785
+
8786
+ .sd-notes-edit {
8787
+ min-height: 84px;
8788
+ resize: vertical;
8789
+ line-height: 1.6;
8790
+ }
8791
+
8792
+ .sd-notes {
8793
+ display: grid;
8794
+ gap: 6px;
8795
+ margin-top: 8px;
8796
+ padding: 14px 18px;
8797
+ border: 1px solid var(--line);
8798
+ border-radius: var(--radius-sm);
8799
+ background: var(--surface);
8800
+ line-height: 1.6;
8801
+ }
8802
+
8803
+ .sd-notes p {
8804
+ margin: 0;
8805
+ }
8806
+
8807
+ /* Presenting: the slide fills the screen, letterboxed in its own sky colour; the controls fade when the mouse rests. */
8808
+ .sd-present {
8809
+ position: fixed;
8810
+ inset: 0;
8811
+ z-index: 1000;
8812
+ background: #000;
8813
+ cursor: pointer;
8814
+ }
8815
+
8816
+ .sd-present.light {
8817
+ background: #e9eef9;
8818
+ }
8819
+
8820
+ .sd-present-stage {
8821
+ position: absolute;
8822
+ inset: 0;
8823
+ }
8824
+
8825
+ .sd-present-bar {
8826
+ position: absolute;
8827
+ left: 50%;
8828
+ bottom: 22px;
8829
+ display: flex;
8830
+ align-items: center;
8831
+ gap: 10px;
8832
+ padding: 8px 12px;
8833
+ transform: translateX(-50%);
8834
+ border: 1px solid var(--line-strong);
8835
+ border-radius: 999px;
8836
+ background: rgba(0, 0, 0, 0.72);
8837
+ color: var(--white);
8838
+ cursor: default;
8839
+ transition: opacity 0.3s;
8840
+ }
8841
+
8842
+ .sd-present.idle {
8843
+ cursor: none;
8844
+ }
8845
+
8846
+ .sd-present.idle .sd-present-bar {
8847
+ opacity: 0;
8848
+ }
8849
+
8850
+ .sd-count {
8851
+ min-width: 64px;
8852
+ text-align: center;
8853
+ font: 500 0.85rem var(--font-mono, monospace);
8854
+ }
8855
+
8856
+ @media (max-width: 760px) {
8857
+ .sd-opts {
8858
+ grid-template-columns: 1fr;
8859
+ }
8860
+
8861
+ .sd-opts .vw-chips {
8862
+ flex-wrap: wrap;
8863
+ }
8864
+
8865
+ .modal:has(.sd-view) .modal-head {
8866
+ flex-wrap: wrap;
8867
+ }
8868
+
8869
+ .sd-icon-btn {
8870
+ width: 34px;
8871
+ height: 34px;
8872
+ }
8873
+ }
8874
+
8875
+ @media (prefers-reduced-motion: reduce) {
8876
+ .sd-present-bar,
8877
+ .sd-item,
8878
+ .sd-slide,
8879
+ .sd-icon-btn,
8880
+ .modal.modal-wide:has(.sd-view),
8881
+ .sd-theme {
8882
+ transition: none;
8883
+ }
8884
+ }
8885
+
8886
+
8887
+ /* ---- Notebook: side panels resized by dragging the line between a panel and the chat (components/PaneResizer) ---- */
8888
+
8889
+ .nbv-resizer {
8890
+ display: none;
8891
+ }
8892
+
8893
+ @media (min-width: 900px) {
8894
+ .nbv-resizer {
8895
+ position: absolute;
8896
+ top: 0;
8897
+ bottom: 0;
8898
+ z-index: 6;
8899
+ display: block;
8900
+ width: 12px;
8901
+ margin-left: -6px;
8902
+ cursor: col-resize;
8903
+ touch-action: none;
8904
+ }
8905
+
8906
+ .nbv-resizer::after {
8907
+ content: "";
8908
+ position: absolute;
8909
+ top: 14px;
8910
+ bottom: 14px;
8911
+ left: 5px;
8912
+ width: 2px;
8913
+ border-radius: 2px;
8914
+ background: transparent;
8915
+ transition: background 0.15s, box-shadow 0.15s;
8916
+ }
8917
+
8918
+ .nbv-resizer:hover::after,
8919
+ .nbv-resizer.dragging::after,
8920
+ .nbv-resizer:focus-visible::after {
8921
+ background: var(--blue);
8922
+ box-shadow: 0 0 10px var(--blue-60);
8923
+ }
8924
+
8925
+ .nbv-resizer:focus-visible {
8926
+ outline: none;
8927
+ }
8928
+ }
8929
+
8930
+ /* While dragging: no text selection, the resize cursor everywhere, and frames (slides, infographics) let the pointer
8931
+ through so the drag is never lost over them. */
8932
+ body.nbv-resizing,
8933
+ body.nbv-resizing * {
8934
+ cursor: col-resize !important;
8935
+ user-select: none;
8936
+ }
8937
+
8938
+ body.nbv-resizing iframe {
8939
+ pointer-events: none;
8940
+ }
8941
+
8942
+ /* The panels' contents follow the panels' own widths, not the window's. */
8943
+ .nbv-side,
8944
+ .nbv-history,
8945
+ .nbv-chat {
8946
+ container-type: inline-size;
8947
+ }
8948
+
8949
+ /* Create tiles never wrap a word: the label shortens with an ellipsis, the icon stays. */
8950
+ .nbv-make > button,
8951
+ .nbv-make .nbv-make-label {
8952
+ min-width: 0;
8953
+ overflow: hidden;
8954
+ }
8955
+
8956
+ .nbv-make .nbv-make-label {
8957
+ flex: 1;
8958
+ }
8959
+
8960
+ .nbv-make-text {
8961
+ min-width: 0;
8962
+ overflow: hidden;
8963
+ text-overflow: ellipsis;
8964
+ white-space: nowrap;
8965
+ }
8966
+
8967
+ /* Tiles per row by the panel's width: one when narrow, two by default, three or four when the panel is wide. */
8968
+ @container (max-width: 299px) {
8969
+ .nbv-make {
8970
+ grid-template-columns: minmax(0, 1fr);
8971
+ }
8972
+ }
8973
+
8974
+ @container (min-width: 560px) {
8975
+ .nbv-make {
8976
+ grid-template-columns: repeat(3, minmax(0, 1fr));
8977
+ }
8978
+
8979
+ .nbv-make .nbv-make-wide {
8980
+ grid-column: auto;
8981
+ }
8982
+ }
8983
+
8984
+ @container (min-width: 760px) {
8985
+ .nbv-make {
8986
+ grid-template-columns: repeat(4, minmax(0, 1fr));
8987
+ }
8988
+ }
8989
+
8990
+ @media (prefers-reduced-motion: reduce) {
8991
+ .nbv-resizer::after {
8992
+ transition: none;
8993
+ }
8994
+ }
8995
+
8996
+
8997
+ /* ---- Audio overview settings (components/AudioOverview) ---- */
8998
+
8999
+ .ao-dialog {
9000
+ gap: 20px;
9001
+ }
9002
+
9003
+ .ao-styles {
9004
+ display: grid;
9005
+ grid-template-columns: repeat(4, minmax(0, 1fr));
9006
+ gap: 10px;
9007
+ }
9008
+
9009
+ .ao-style {
9010
+ display: grid;
9011
+ align-content: start;
9012
+ gap: 8px;
9013
+ min-height: 132px;
9014
+ padding: 14px 14px 16px;
9015
+ text-align: left;
9016
+ border: 1px solid var(--line-strong);
9017
+ border-radius: 18px;
9018
+ background: rgba(0, 0, 0, 0.6);
9019
+ color: var(--text);
9020
+ cursor: pointer;
9021
+ transition: border-color 0.15s, background 0.15s;
9022
+ }
9023
+
9024
+ .ao-style:hover:not(:disabled):not(.on) {
9025
+ border-color: var(--blue-60);
9026
+ }
9027
+
9028
+ .ao-style.on {
9029
+ border-color: var(--blue);
9030
+ background: var(--blue-20);
9031
+ }
9032
+
9033
+ .ao-style:disabled {
9034
+ opacity: 0.45;
9035
+ cursor: not-allowed;
9036
+ }
9037
+
9038
+ .ao-style-head {
9039
+ display: flex;
9040
+ align-items: center;
9041
+ justify-content: space-between;
9042
+ gap: 8px;
9043
+ }
9044
+
9045
+ .ao-style-head strong {
9046
+ font-weight: 600;
9047
+ }
9048
+
9049
+ .ao-radio {
9050
+ display: grid;
9051
+ place-items: center;
9052
+ flex: none;
9053
+ width: 22px;
9054
+ height: 22px;
9055
+ border-radius: 50%;
9056
+ border: 1px solid var(--line-strong);
9057
+ }
9058
+
9059
+ .ao-style.on .ao-radio {
9060
+ border-color: var(--blue);
9061
+ background: var(--black);
9062
+ }
9063
+
9064
+ .ao-style-blurb {
9065
+ font-size: 0.85rem;
9066
+ line-height: 1.45;
9067
+ color: var(--text-2);
9068
+ }
9069
+
9070
+ /* Hosts pills share the slide deck's pill look (.sd-opts); the two voice pickers sit side by side */
9071
+ .ao-voices {
9072
+ display: grid;
9073
+ grid-template-columns: repeat(2, minmax(0, 1fr));
9074
+ gap: 14px;
9075
+ }
9076
+
9077
+ .ao-voices > p {
9078
+ grid-column: 1 / -1;
9079
+ margin: 0;
9080
+ }
9081
+
9082
+ @media (max-width: 760px) {
9083
+ .ao-styles {
9084
+ grid-template-columns: repeat(2, minmax(0, 1fr));
9085
+ }
9086
+
9087
+ .ao-voices {
9088
+ grid-template-columns: 1fr;
9089
+ }
9090
+ }
9091
+
9092
+ @media (prefers-reduced-motion: reduce) {
9093
+ .ao-style {
9094
+ transition: none;
9095
+ }
9096
+ }
9097
+
9098
+ .modal.modal-wide:has(.ao-dialog) {
9099
+ width: min(880px, calc(100vw - 32px));
9100
+ }
9101
+
9102
+ /* ---- Audio overview in the notebook's list (components/AudioOverview: AudioOverviewCard) ---- */
9103
+
9104
+ .ao-card {
9105
+ border: 1px solid var(--line);
9106
+ border-radius: var(--radius);
9107
+ background: var(--surface);
9108
+ transition: border-color 0.15s;
9109
+ }
9110
+
9111
+ .ao-card.open {
9112
+ border-color: var(--line-strong);
9113
+ }
9114
+
9115
+ .ao-card-head {
9116
+ display: grid;
9117
+ grid-template-columns: 40px minmax(0, 1fr) auto;
9118
+ align-items: center;
9119
+ gap: 10px;
9120
+ padding: 10px 10px 10px 8px;
9121
+ border-radius: var(--radius);
9122
+ outline: none;
9123
+ }
9124
+
9125
+ .ao-card-head[role="button"] {
9126
+ cursor: pointer;
9127
+ }
9128
+
9129
+ .ao-card-head[role="button"]:hover {
9130
+ background: var(--surface-2);
9131
+ }
9132
+
9133
+ .ao-card-head:focus-visible {
9134
+ box-shadow: 0 0 0 2px var(--blue);
9135
+ }
9136
+
9137
+ .ao-card-icon {
9138
+ display: grid;
9139
+ place-items: center;
9140
+ width: 40px;
9141
+ height: 40px;
9142
+ color: var(--blue);
9143
+ }
9144
+
9145
+ .ao-card-text {
9146
+ display: grid;
9147
+ gap: 2px;
9148
+ min-width: 0;
9149
+ }
9150
+
9151
+ .ao-card-text strong {
9152
+ overflow: hidden;
9153
+ text-overflow: ellipsis;
9154
+ white-space: nowrap;
9155
+ font-weight: 500;
9156
+ font-size: 0.95rem;
9157
+ }
9158
+
9159
+ .ao-card-meta {
9160
+ overflow: hidden;
9161
+ text-overflow: ellipsis;
9162
+ white-space: nowrap;
9163
+ font-size: 0.82rem;
9164
+ color: var(--text-3);
9165
+ }
9166
+
9167
+ .ao-chevron {
9168
+ display: grid;
9169
+ place-items: center;
9170
+ width: 32px;
9171
+ height: 32px;
9172
+ color: var(--text-2);
9173
+ }
9174
+
9175
+ .ao-card-progress {
9176
+ padding: 0 12px 10px 58px;
9177
+ }
9178
+
9179
+ .ao-card-body {
9180
+ display: grid;
9181
+ gap: 12px;
9182
+ padding: 4px 14px 14px;
9183
+ }
9184
+
9185
+ /* The player: play, time, a seek bar and the speed, in one pill */
9186
+ .ao-player {
9187
+ display: grid;
9188
+ grid-template-columns: auto auto minmax(0, 1fr) auto;
9189
+ align-items: center;
9190
+ gap: 12px;
9191
+ padding: 6px 8px 6px 6px;
9192
+ border-radius: 999px;
9193
+ background: var(--surface-2);
9194
+ }
9195
+
9196
+ .ao-play {
9197
+ display: grid;
9198
+ place-items: center;
9199
+ width: 38px;
9200
+ height: 38px;
9201
+ border: 0;
9202
+ border-radius: 50%;
9203
+ background: var(--blue);
9204
+ color: var(--white);
9205
+ cursor: pointer;
9206
+ }
9207
+
9208
+ .ao-play:focus-visible,
9209
+ .ao-speed:focus-visible {
9210
+ outline: 2px solid var(--blue);
9211
+ outline-offset: 2px;
9212
+ }
9213
+
9214
+ .ao-time {
9215
+ font-size: 0.8rem;
9216
+ color: var(--text-2);
9217
+ white-space: nowrap;
9218
+ }
9219
+
9220
+ .ao-seek {
9221
+ width: 100%;
9222
+ height: 4px;
9223
+ margin: 0;
9224
+ border-radius: 2px;
9225
+ background: linear-gradient(to right, var(--blue) var(--ao-fill, 0%), var(--line-strong) var(--ao-fill, 0%));
9226
+ appearance: none;
9227
+ cursor: pointer;
9228
+ }
9229
+
9230
+ .ao-seek::-webkit-slider-thumb {
9231
+ width: 14px;
9232
+ height: 14px;
9233
+ border-radius: 50%;
9234
+ background: var(--white);
9235
+ appearance: none;
9236
+ }
9237
+
9238
+ .ao-seek::-moz-range-thumb {
9239
+ width: 14px;
9240
+ height: 14px;
9241
+ border: 0;
9242
+ border-radius: 50%;
9243
+ background: var(--white);
9244
+ }
9245
+
9246
+ .ao-speed {
9247
+ min-width: 52px;
9248
+ height: 30px;
9249
+ padding: 0 10px;
9250
+ border: 1px solid var(--line-strong);
9251
+ border-radius: 999px;
9252
+ background: transparent;
9253
+ color: var(--text);
9254
+ font-size: 0.8rem;
9255
+ cursor: pointer;
9256
+ }
9257
+
9258
+ .ao-speed:hover {
9259
+ border-color: var(--blue-60);
9260
+ }
9261
+
9262
+ .ao-transcript-toggle {
9263
+ justify-self: start;
9264
+ }
9265
+
9266
+ .ao-card-actions {
9267
+ gap: 8px;
9268
+ }
9269
+
9270
+ /* Delete reads as a normal button (not dimmed), red when pointed at */
9271
+ .ao-delete:hover,
9272
+ .ao-delete:focus-visible {
9273
+ border-color: #ef4444;
9274
+ color: #ff8a8a;
9275
+ }
9276
+
9277
+ @media (prefers-reduced-motion: reduce) {
9278
+ .ao-card {
9279
+ transition: none;
9280
+ }
9281
+ }