Muhammad-Mehdi commited on
Commit
fa8df4c
·
1 Parent(s): 9b5cc9d

slide deck and audio implemented

Browse files
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,18 @@ 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 +43,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 +66,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 +160,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 +196,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 +279,24 @@ 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 stored as stored_deck
23
+ from app.slides.themes import theme_id as slide_theme_id
24
 
25
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
26
 
27
  ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
28
+ "flashcards", "report", "infographic", "slide_deck"]
29
 
30
 
31
+ # Made from posts or chats picked in the dialog
32
+ SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic", "slide_deck", "audio_overview")
33
 
34
 
35
  class RenderIn(BaseModel):
 
43
  document_id: uuid.UUID | None = None
44
  archive: bool = False # no notebook or post picked: use the "All posts" notebook
45
  # audio_overview
46
+ format: Literal["deep_dive", "brief", "critique", "debate"] = "deep_dive"
47
+ hosts: Literal[1, 2] = 2 # audio_overview: a single narrator or a two host conversation
48
  minutes: int = Field(6, ge=1, le=30)
49
  # the two hosts' voices, from the workspace's voices (none: the defaults)
50
  host_a: str | None = Field(None, max_length=100)
 
66
  question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
67
  default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
68
  language: str | None = Field(None, max_length=40)
69
+ theme: str | None = Field(None, max_length=30) # infographic: app.infographics.themes; slide_deck: app.slides.themes
70
+ # slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
71
+ deck_format: Literal["detailed", "presenter"] = "detailed"
72
+ deck_length: Literal["short", "default", "long"] = "default"
73
  # report: a written "document" or an "interactive" one, the template it started from, and the instructions the
74
  # writer is given (the dialog's editable text; empty means the template's own)
75
  report_format: Literal["document", "interactive"] = "document"
 
160
  raise HTTPException(400, "Pick at least one post or chat to make this from")
161
  target = _target_title(ctx, body.notebook_id, body.document_id)
162
  if body.type == "audio_overview":
163
+ if body.format == "debate" and body.hosts == 1:
164
+ raise HTTPException(400, "A debate needs two hosts")
165
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
166
+ ids = body.document_ids or ([body.document_id] if body.document_id else [])
167
+ hosts = _hosts(ctx, body)
168
+ if body.hosts == 1:
169
+ hosts.pop("host_b", None)
170
+ params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
171
+ "format": body.format, "minutes": body.minutes, "hosts": body.hosts, "language": body.language,
172
+ "instructions": (body.instructions or "").strip(), **hosts}
173
  title = f"Audio overview: {target}"
174
  elif body.type == "mind_map":
175
  if not body.notebook_id:
 
196
  params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
197
  "theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
198
  title = f"Infographic: {params['instructions'][:60] or target}"
199
+ elif body.type == "slide_deck":
200
+ ids = body.document_ids or ([body.document_id] if body.document_id else [])
201
+ params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
202
+ "theme": slide_theme_id(body.theme), "deck_format": body.deck_format, "deck_length": body.deck_length,
203
+ "language": body.language, "instructions": (body.instructions or "").strip()}
204
+ title = f"Slide deck: {target}"
205
  elif body.type == "quiz":
206
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
207
  params = {"document_ids": [str(i) for i in ids],
 
279
  return Response(png, media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{name} ({layout}).png"'})
280
 
281
 
282
+ @router.get("/{artifact_id}/slides.{ext}")
283
+ def slide_deck_file(artifact_id: uuid.UUID, ext: Literal["pdf", "pptx"], ctx: Ctx = Depends(get_ctx)):
284
+ """A finished slide deck as a PDF (exactly as shown) or an editable PowerPoint."""
285
+ a = get_artifact_or_404(ctx, artifact_id)
286
+ got = stored_deck(a.content_json or {}) if a.type == "slide_deck" and a.status == "ready" else None
287
+ if not got:
288
+ raise HTTPException(404, "This slide deck has nothing to download yet.")
289
+ deck, theme, fmt, seed = got
290
+ try:
291
+ data = (slide_export.render_pdf if ext == "pdf" else slide_export.build_pptx)(deck, theme, fmt, seed)
292
+ except ImageUnavailable as exc:
293
+ raise HTTPException(503, str(exc)) from exc
294
+ name = re.sub(r"[^\w\- ]+", "", deck.get("title") or "Slide deck").strip()[:80] or "Slide deck"
295
+ media = "application/pdf" if ext == "pdf" else \
296
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation"
297
+ return Response(data, media_type=media, headers={"Content-Disposition": f'attachment; filename="{name}.{ext}"'})
298
+
299
+
300
  @router.patch("/{artifact_id}")
301
  def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
302
  a = get_artifact_or_404(ctx, artifact_id)
backend/app/services/artifacts.py CHANGED
@@ -9,6 +9,7 @@ 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 +23,7 @@ TYPE_LABELS = {
22
  "flashcards": "Flashcards",
23
  "report": "Report",
24
  "infographic": "Infographic",
 
25
  "upload": "Upload",
26
  }
27
 
@@ -84,6 +86,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 []
 
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 view as slide_pages
13
 
14
  TYPE_LABELS = {
15
  "summary": "Summary",
 
23
  "flashcards": "Flashcards",
24
  "report": "Report",
25
  "infographic": "Infographic",
26
+ "slide_deck": "Slide deck",
27
  "upload": "Upload",
28
  }
29
 
 
86
  from app.pipeline.infographic import infographic_html # not at import: pipeline imports this module
87
 
88
  content = {**content, "html": infographic_html(content), "html_landscape": infographic_html(content, "landscape")}
89
+ if a.type == "slide_deck" and a.status == "ready":
90
+ content = {**content, "slides_html": slide_pages(content)}
91
  if a.type == "report" and any(b.get("type") == "infographic" for b in content.get("blocks") or []):
92
  content = {**content, "blocks": report_blocks(a)}
93
  slides = content.get("slide_keys") or []
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,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. `view` reads a stored deck back (clamped again, so older or edited decks
3
+ still draw) and gives its pages; one slide that cannot be drawn becomes a plain one instead of breaking the deck."""
4
+
5
+ import copy
6
+ import logging
7
+
8
+ from app.infographics.image import ImageUnavailable
9
+ from app.slides import export
10
+ from app.slides.content import clamp_deck, clamp_slide, empty_slide
11
+ from app.slides.layout import SCALES, assign_variants, compose, seed_of
12
+ from app.slides.render import slide_html
13
+ from app.slides.themes import theme_id
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ def fit(deck: dict, theme: str, fmt: str, seed: int) -> dict:
19
+ """Every slide as it will be drawn: text shortened where it had to be, so what is stored is what is shown."""
20
+ out = copy.deepcopy(deck)
21
+ out["slides"] = [compose(out, i, theme, fmt, seed).slide for i in range(len(out["slides"]))]
22
+ return out
23
+
24
+
25
+ def finish(deck: dict, theme: str, fmt: str, seed: int, previous: list[tuple[str, str]] | None = None) -> dict:
26
+ """Variants, fitted text, and a Chrome check that no text box overflows (slides that do are drawn a step
27
+ smaller and fitted again). Raises ValueError when a slide cannot be drawn at all."""
28
+ theme = theme_id(theme)
29
+ deck = copy.deepcopy(deck)
30
+ assign_variants(deck["slides"], theme, seed, previous)
31
+ deck = fit(deck, theme, fmt, seed)
32
+ for _ in range(3):
33
+ try:
34
+ bad = export.overflowing(deck, theme, fmt, seed)
35
+ except ImageUnavailable:
36
+ break # no Chrome here: the measured fit alone keeps text inside its boxes
37
+ if not bad:
38
+ break
39
+ log.info("slides %s overflow in Chrome, drawing them smaller", sorted(bad))
40
+ for i in bad:
41
+ deck["slides"][i]["shrink"] = min(int(deck["slides"][i].get("shrink") or 0) + 1, len(SCALES) - 1)
42
+ deck = fit(deck, theme, fmt, seed)
43
+ for i in range(len(deck["slides"])):
44
+ slide_html(deck, i, theme, fmt, seed) # raises if a slide cannot be drawn: better now than when shown
45
+ return deck
46
+
47
+
48
+ def stored(content: dict) -> tuple[dict, str, str, int] | None:
49
+ """A stored deck, clamped, with its theme, format and seed (None when there is none)."""
50
+ fmt = content.get("format") if content.get("format") in ("detailed", "presenter") else "detailed"
51
+ deck = clamp_deck(content.get("deck") or {}, fmt)
52
+ if not deck:
53
+ return None
54
+ return deck, theme_id(content.get("theme")), fmt, seed_of(content.get("seed"))
55
+
56
+
57
+ def _placeholder(deck: dict, index: int) -> dict:
58
+ s = empty_slide("section")
59
+ s["heading"] = deck.get("title") or "Slide"
60
+ return clamp_slide(s, "detailed")
61
+
62
+
63
+ def view(content: dict) -> list[str]:
64
+ """Each slide of a stored deck as a standalone page."""
65
+ got = stored(content)
66
+ if not got:
67
+ return []
68
+ deck, theme, fmt, seed = got
69
+ pages = []
70
+ for i in range(len(deck["slides"])):
71
+ try:
72
+ pages.append(slide_html(deck, i, theme, fmt, seed))
73
+ except Exception:
74
+ log.exception("slide %s could not be drawn", i)
75
+ safe = copy.deepcopy(deck)
76
+ safe["slides"][i] = _placeholder(deck, i)
77
+ pages.append(slide_html(safe, i, theme, fmt, seed))
78
+ return pages
backend/app/slides/content.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ def empty_slide(layout: str = "points") -> dict:
115
+ return {"layout": layout, "variant": "", "kicker": "", "heading": "", "lead": "", "points": [],
116
+ "left": {"label": "", "items": []}, "right": {"label": "", "items": []},
117
+ "stat": {"value": "", "label": ""}, "quote": {"text": "", "by": ""}, "takeaways": [], "closing": "",
118
+ "notes": "", "sources": [], "shrink": 0}
119
+
120
+
121
+ def clamp_slide(raw: object, fmt: str) -> dict | None:
122
+ """One slide in the fixed shape, or None when nothing on it can be shown."""
123
+ lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
124
+ r = _dict(raw)
125
+ s = empty_slide(r.get("layout") if r.get("layout") in LAYOUTS else "points")
126
+ s["variant"] = plain(r.get("variant"))[:4].lower()
127
+ s["kicker"] = clip(r.get("kicker"), lim["kicker"])
128
+ s["heading"] = clip(r.get("heading"), lim["title"] if s["layout"] == "title" else lim["heading"])
129
+ s["lead"] = clip(r.get("lead"), lim["subtitle"] if s["layout"] == "title" else lim["lead"])
130
+ lo, hi = lim["points"]
131
+ points = _points(r.get("points"), lim)
132
+ if s["layout"] == "agenda":
133
+ s["points"] = [{"term": "", "text": clip(p["text"], lim["agenda"])} for p in points][: lim["agenda_items"]]
134
+ else:
135
+ s["points"] = points[:hi]
136
+ s["left"], s["right"] = _column(r.get("left"), lim), _column(r.get("right"), lim)
137
+ stat = _dict(r.get("stat"))
138
+ s["stat"] = {"value": clip(stat.get("value"), lim["value"]), "label": clip(stat.get("label"), lim["stat_label"])}
139
+ quote = _dict(r.get("quote"))
140
+ s["quote"] = {"text": clip(quote.get("text"), lim["quote"]).strip("\"'“”"), "by": clip(quote.get("by"), lim["by"])}
141
+ s["takeaways"] = [t for t in (clip(t, lim["takeaway"]) for t in _list(r.get("takeaways"))) if t][: lim["takeaways"]]
142
+ s["closing"] = clip(r.get("closing"), lim["closing"])
143
+ s["notes"] = clip(r.get("notes"), lim["notes"])
144
+ s["sources"] = _sources(r.get("sources"))
145
+ try:
146
+ s["shrink"] = max(0, min(int(r.get("shrink") or 0), 4))
147
+ except (TypeError, ValueError):
148
+ s["shrink"] = 0
149
+
150
+ # A layout missing what it needs becomes a simpler one, so the drawing never meets an empty slot.
151
+ lay = s["layout"]
152
+ if lay == "two_column" and not (s["left"]["items"] and s["right"]["items"]):
153
+ merged = [{"term": "", "text": t} for t in s["left"]["items"] + s["right"]["items"]]
154
+ s["points"] = s["points"] or merged[:hi]
155
+ lay = "points"
156
+ if lay == "stat" and not (s["stat"]["value"] and s["stat"]["label"]):
157
+ lay = "points"
158
+ if lay == "quote" and not s["quote"]["text"]:
159
+ lay = "points"
160
+ if lay == "agenda" and len(s["points"]) < 2:
161
+ lay = "section"
162
+ if lay == "closing" and not s["takeaways"] and not s["closing"]:
163
+ lay = "section"
164
+ if lay == "points" and len(s["points"]) < lo:
165
+ if s["points"] and not s["lead"]: # one point: say it as the slide's lead
166
+ s["lead"] = clip(s["points"][0]["text"], lim["lead"])
167
+ s["points"] = []
168
+ lay = "section"
169
+ if lay != s["layout"]:
170
+ s["variant"] = ""
171
+ s["layout"] = lay
172
+ if not s["heading"]:
173
+ s["heading"] = s["kicker"] or (s["closing"] if lay == "closing" else "")
174
+ if not s["heading"] and lay not in ("quote", "closing"):
175
+ return None
176
+ return s
177
+
178
+
179
+ def clamp_deck(raw: object, fmt: str) -> dict | None:
180
+ """The deck in the fixed shape, or None when it has no slide that can be shown."""
181
+ r = _dict(raw)
182
+ lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
183
+ slides = [s for s in (clamp_slide(x, fmt) for x in _list(r.get("slides"))) if s]
184
+ if not slides:
185
+ return None
186
+ title = clip(r.get("title"), lim["title"]) or (slides[0]["heading"] if slides[0]["layout"] == "title" else "") or "Slide deck"
187
+ return {"title": title, "subtitle": clip(r.get("subtitle"), lim["subtitle"]), "slides": slides}
backend/app/slides/decor.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 | 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
+
28
+
29
+ @dataclass
30
+ class Decor:
31
+ hot: tuple[float, float] # where the sky is brightest
32
+ shapes: list[Shape]
33
+
34
+
35
+ # Where a variant has room for a drawing: (x0, y0, x1, y1). None: only stars and a soft glow.
36
+ FREE = {
37
+ "dark-space": {
38
+ ("points", "a"): (1520, 140, 1900, 940), ("points", "c"): None, ("points", "b"): None, ("points", "d"): None,
39
+ ("agenda", "a"): (120, 660, 820, 1000), ("agenda", "b"): None,
40
+ ("two_column", "a"): None, ("two_column", "b"): None,
41
+ ("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (140, 60, 760, 140),
42
+ ("section", "b"): (1500, 60, 1900, 560), ("closing", "b"): None, ("closing", "c"): (140, 860, 960, 1060),
43
+ },
44
+ "light-space": {
45
+ ("points", "a"): None, ("points", "b"): None, ("points", "c"): None, ("points", "d"): None,
46
+ ("agenda", "a"): None, ("agenda", "b"): (120, 760, 820, 1000),
47
+ ("two_column", "a"): None, ("two_column", "b"): None,
48
+ ("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (100, 360, 400, 760),
49
+ ("closing", "a"): None, ("closing", "b"): None, ("closing", "c"): None,
50
+ },
51
+ }
52
+
53
+
54
+ def _stars(rnd: random.Random, dark: bool) -> Shape:
55
+ n = 120 if dark else 70
56
+ pts = []
57
+ for _ in range(n):
58
+ if dark:
59
+ r, op = rnd.choice((0.8, 1.0, 1.2, 1.2, 1.6, 2.2)), round(rnd.uniform(0.18, 0.85), 2)
60
+ else:
61
+ r, op = rnd.choice((0.9, 1.2, 1.5, 2.0)), round(rnd.uniform(0.08, 0.26), 2)
62
+ pts.append((rnd.uniform(0, W), rnd.uniform(0, H), r, op))
63
+ return Shape("stars", points=pts, color="star")
64
+
65
+
66
+ def _constellation(rnd: random.Random, box: tuple[float, float, float, float], n: int = 7) -> Shape:
67
+ x0, y0, x1, y1 = box
68
+ 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)]
69
+ pts.sort(key=lambda p: p[0])
70
+ edges = [(i, i + 1) for i in range(n - 1)]
71
+ if n > 4:
72
+ edges.append((rnd.randrange(0, n - 3), rnd.randrange(n - 2, n)))
73
+ return Shape("const", points=pts, edges=edges, color="accent", opacity=0.85)
74
+
75
+
76
+ def build(theme: str, layout: str, variant: str, seed: int, index: int) -> Decor:
77
+ """The drawing for one slide."""
78
+ dark = theme == "dark-space"
79
+ rnd = random.Random(int(hashlib.sha1(f"{seed}:{index}:{layout}:{variant}".encode()).hexdigest()[:12], 16))
80
+ j = lambda span: rnd.uniform(-span, span) # noqa: E731 (a small, seeded nudge)
81
+ shapes = [_stars(rnd, dark)]
82
+ hot = (W * rnd.uniform(0.25, 0.75), H * rnd.uniform(0.15, 0.5))
83
+ key = (layout, variant)
84
+
85
+ if dark:
86
+ if key == ("title", "a"):
87
+ hot = (960, 760)
88
+ shapes += [Shape("glow", 960, 900, 900, color="accent2", opacity=0.55),
89
+ Shape("planet", 960 + j(60), 1080 + 560 + j(40), 820),
90
+ Shape("ring", 960, 860 + j(20), rx=1150, ry=150, rot=-4 + j(3), color="accent", opacity=0.45, width=2.5)]
91
+ elif key == ("title", "b"):
92
+ cx, cy = 1520 + j(60), 300 + j(50)
93
+ hot = (cx, cy)
94
+ shapes += [Shape("glow", cx, cy, 520, color="accent2", opacity=0.5), Shape("planet", cx, cy, 230 + j(25)),
95
+ Shape("ring", cx, cy, rx=400, ry=78, rot=-18 + j(6), color="accent", opacity=0.55, width=2.5)]
96
+ elif key == ("title", "c"):
97
+ hot = (1450, 540)
98
+ shapes += [Shape("glow", 1450, 540, 620, color="accent2", opacity=0.35),
99
+ _constellation(rnd, (1120, 170, 1800, 910), 8)]
100
+ elif layout == "section" and variant == "a":
101
+ hot = (960, 540)
102
+ shapes += [Shape("glow", 960, 540, 760, color="accent2", opacity=0.35),
103
+ Shape("ring", 960, 540 + j(20), rx=980, ry=260, rot=-8 + j(5), color="accent", opacity=0.3, width=2)]
104
+ elif layout == "closing" and variant == "a":
105
+ hot = (960, 980)
106
+ shapes += [Shape("glow", 960, 1120, 760, color="accent2", opacity=0.5),
107
+ Shape("ring", 960, 1210, rx=1080, ry=880, rot=0, color="accent", opacity=0.35, width=2.5)]
108
+ else:
109
+ corner = rnd.choice(((0, 0), (W, 0), (0, H), (W, H)))
110
+ shapes.append(Shape("glow", corner[0], corner[1], 700, color="accent2", opacity=0.32))
111
+ hot = (corner[0] * 0.8 + W * 0.1, corner[1] * 0.8 + H * 0.1)
112
+ free = FREE["dark-space"].get(key)
113
+ if free:
114
+ x0, y0, x1, y1 = free
115
+ r = min(x1 - x0, y1 - y0) * rnd.uniform(0.26, 0.34)
116
+ cx, cy = rnd.uniform(x0 + r, x1 - r), rnd.uniform(y0 + r, y1 - r)
117
+ shapes += [Shape("glow", cx, cy, r * 2.2, color="accent2", opacity=0.35), Shape("planet", cx, cy, r),
118
+ Shape("ring", cx, cy, rx=r * 1.7, ry=r * 0.34, rot=-16 + j(10), color="accent", opacity=0.5, width=2)]
119
+ else:
120
+ ring = lambda cx, cy, r, op=0.3: Shape("ring", cx, cy, rx=r, ry=r, color="accent2", opacity=op, width=2) # noqa: E731
121
+ if key == ("title", "a"):
122
+ cx, cy = 2010 + j(40), 540 + j(60)
123
+ hot = (1500, 540)
124
+ shapes += [ring(cx, cy, 360), ring(cx, cy, 540, 0.24), ring(cx, cy, 740, 0.18)]
125
+ a = math.radians(200 + j(20))
126
+ shapes.append(Shape("planet", cx + 540 * math.cos(a), cy + 540 * math.sin(a), 34))
127
+ elif key == ("title", "b"):
128
+ hot = (1500, 820)
129
+ shapes += [Shape("glow", 1560, 840, 520, color="accent", opacity=0.12),
130
+ _constellation(rnd, (1360, 660, 1800, 980), 7)]
131
+ elif key == ("title", "c"):
132
+ hot = (960, 540)
133
+ shapes += [Shape("ring", 960, 520, rx=900, ry=330, rot=-10 + j(4), color="accent2", opacity=0.28, width=2.5),
134
+ Shape("ring", 960, 520, rx=1040, ry=400, rot=-10 + j(4), color="accent2", opacity=0.14, width=2)]
135
+ a = math.radians(rnd.choice((20, 160, 200, 340)) + j(10))
136
+ shapes.append(Shape("planet", 960 + 900 * math.cos(a), 520 + 330 * math.sin(a), 30))
137
+ elif key == ("section", "a"):
138
+ shapes += [ring(1960, 560, 300), ring(1960, 560, 460, 0.22), ring(1960, 560, 640, 0.15),
139
+ Shape("planet", 1960 - 460 + 6, 560 + j(80), 26)]
140
+ elif key == ("section", "b"):
141
+ shapes += [ring(-40, 560, 300), ring(-40, 560, 460, 0.22), ring(-40, 560, 640, 0.15),
142
+ Shape("planet", -40 + 460 - 6, 560 + j(80), 26)]
143
+ elif key == ("closing", "c"):
144
+ hot = (960, 860)
145
+ shapes += [Shape("ring", 960, 540, rx=1000, ry=470, rot=6 + j(4), color="accent2", opacity=0.2, width=2.5)]
146
+ else:
147
+ corner = rnd.choice(((W + 60, -60), (W + 60, H + 60), (-60, H + 60)))
148
+ shapes += [ring(corner[0], corner[1], 260, 0.22), ring(corner[0], corner[1], 400, 0.15)]
149
+ free = FREE["light-space"].get(key)
150
+ if free:
151
+ shapes.append(_constellation(rnd, free, 5))
152
+ return Decor(hot, shapes)
backend/app/slides/export.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
26
+ from app.slides.layout import El, compose
27
+ from app.slides.render import color, deck_print_html, planet_colors
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
+ # --- overflow check -------------------------------------------------------------------------------------------------
92
+
93
+ _MEASURE = """<script>
94
+ document.fonts.ready.then(function () {
95
+ var bad = [];
96
+ document.querySelectorAll('.slide').forEach(function (s, i) {
97
+ s.querySelectorAll('.t').forEach(function (t) {
98
+ var inner = t.firstElementChild; // the text's own height, not its glyphs' ink
99
+ if (inner.offsetHeight > t.clientHeight + 2 || inner.scrollWidth > t.clientWidth + 2) { if (bad.indexOf(i) < 0) bad.push(i); }
100
+ });
101
+ });
102
+ var out = document.createElement('pre'); out.id = 'overflow'; out.textContent = 'OVERFLOW:' + bad.join(',') + ':END';
103
+ document.body.appendChild(out);
104
+ });
105
+ </script>"""
106
+
107
+
108
+ def overflowing(deck: dict, theme: str, fmt: str, seed: int) -> set[int]:
109
+ """Indexes of slides where Chrome finds text taller or wider than its box. Raises ImageUnavailable without Chrome."""
110
+ html = deck_print_html(deck, theme_id(theme), fmt, seed, script=_MEASURE)
111
+ _, dom = _run_chrome(html, ["--dump-dom"], None)
112
+ m = re.search(r"OVERFLOW:([0-9,]*):END", dom)
113
+ if not m:
114
+ raise ImageUnavailable("The slides could not be measured.")
115
+ return {int(i) for i in m.group(1).split(",") if i}
116
+
117
+
118
+ # --- PowerPoint -----------------------------------------------------------------------------------------------------
119
+
120
+ def _rgb(hex_: str) -> RGBColor:
121
+ return RGBColor.from_string(hex_.lstrip("#").upper())
122
+
123
+
124
+ def _hex_rgb(hex_: str) -> tuple[int, int, int]:
125
+ h = hex_.lstrip("#")
126
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
127
+
128
+
129
+ def _gradient_disc(size: int, inner: tuple[int, int, int, int], outer: tuple[int, int, int, int]) -> Image.Image:
130
+ """A size x size image fading from `inner` at the centre to `outer` at the edge (RGBA)."""
131
+ mask = Image.radial_gradient("L").resize((size, size)) # 0 in the centre, 255 at the corners
132
+ mask = mask.point(lambda v: min(255, int(v * 1.42))) # 255 at the disc's edge
133
+ a, b = Image.new("RGBA", (size, size), inner), Image.new("RGBA", (size, size), outer)
134
+ return Image.composite(b, a, mask)
135
+
136
+
137
+ def background(decor: Decor, pal: Palette, dark: bool) -> bytes:
138
+ """The slide's sky and drawing as a JPEG, for the PowerPoint's background."""
139
+ hx, hy = decor.hot
140
+ sky = Image.new("RGBA", (W, H), _hex_rgb(pal.bg1) + (255,))
141
+ big = int(W * 1.7)
142
+ disc = _gradient_disc(big, _hex_rgb(pal.bg2) + (255,), _hex_rgb(pal.bg1) + (255,))
143
+ sky.paste(disc, (int(hx - big / 2), int(hy - big / 2)))
144
+ layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
145
+ draw = ImageDraw.Draw(layer)
146
+ for s in decor.shapes:
147
+ _draw(sky, layer, draw, s, pal, dark)
148
+ sky = Image.alpha_composite(sky, layer).convert("RGB")
149
+ buf = io.BytesIO()
150
+ sky.save(buf, "JPEG", quality=90)
151
+ return buf.getvalue()
152
+
153
+
154
+ def _draw(sky: Image.Image, layer: Image.Image, draw: ImageDraw.ImageDraw, s: Shape, pal: Palette, dark: bool) -> None:
155
+ c = _hex_rgb(color(pal, s.color))
156
+ if s.kind == "stars":
157
+ for x, y, r, o in s.points:
158
+ draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * o),))
159
+ elif s.kind == "glow":
160
+ size = int(s.r * 2)
161
+ if size < 4:
162
+ return
163
+ _paste_clipped(sky, _gradient_disc(size, c + (int(255 * s.opacity),), c + (0,)), s.cx - s.r, s.cy - s.r)
164
+ elif s.kind == "planet":
165
+ hi, lo, rim = planet_colors(pal, dark)
166
+ size = int(s.r * 2)
167
+ if size < 4:
168
+ return
169
+ disc = Image.new("RGBA", (size, size), _hex_rgb(lo) + (255,))
170
+ shine = _gradient_disc(int(size * 1.6), _hex_rgb(hi) + (255,), _hex_rgb(lo) + (0,))
171
+ _paste_clipped(disc, shine, -size * 0.45, -size * 0.52) # lit from the upper left, as in the page's gradient
172
+ mask = Image.new("L", (size, size), 0)
173
+ ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
174
+ disc.putalpha(mask)
175
+ _paste_clipped(sky, disc, s.cx - s.r, s.cy - s.r)
176
+ 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)
177
+ elif s.kind == "ring":
178
+ steps = 360
179
+ rot = math.radians(s.rot)
180
+ pts = []
181
+ for k in range(steps + 1):
182
+ a = 2 * math.pi * k / steps
183
+ x, y = s.rx * math.cos(a), s.ry * math.sin(a)
184
+ pts.append((s.cx + x * math.cos(rot) - y * math.sin(rot), s.cy + x * math.sin(rot) + y * math.cos(rot)))
185
+ draw.line(pts, fill=c + (int(255 * s.opacity),), width=max(1, round(s.width)))
186
+ elif s.kind == "const":
187
+ for a, b in s.edges:
188
+ (x1, y1, *_), (x2, y2, *_) = s.points[a], s.points[b]
189
+ draw.line((x1, y1, x2, y2), fill=c + (int(255 * s.opacity * 0.5),), width=2)
190
+ for x, y, r, _ in s.points:
191
+ draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * s.opacity),))
192
+
193
+
194
+ def _paste_clipped(base: Image.Image, img: Image.Image, x: float, y: float) -> None:
195
+ """alpha_composite `img` onto `base` at (x, y), clipping whatever falls outside."""
196
+ x, y = int(x), int(y)
197
+ left, top = max(0, -x), max(0, -y)
198
+ right, bottom = min(img.width, base.width - x), min(img.height, base.height - y)
199
+ if right <= left or bottom <= top:
200
+ return
201
+ base.alpha_composite(img.crop((left, top, right, bottom)), (x + left, y + top))
202
+
203
+
204
+ def _emu(px: float) -> Emu:
205
+ return Emu(int(round(px * EMU_PX)))
206
+
207
+
208
+ def _no_style(shape) -> None:
209
+ """Drop the theme's default shape style (shadows, outlines) so only what we set is drawn."""
210
+ style = shape._element.find(qn("p:style"))
211
+ if style is not None:
212
+ shape._element.remove(style)
213
+
214
+
215
+ def _alpha(fill_fore_color_el, opacity: float) -> None:
216
+ if opacity >= 1:
217
+ return
218
+ clr = fill_fore_color_el.find(qn("a:srgbClr"))
219
+ if clr is not None:
220
+ a = clr.makeelement(qn("a:alpha"), {"val": str(int(opacity * 100000))})
221
+ clr.append(a)
222
+
223
+
224
+ def _add_el(slide, e: El, pal: Palette) -> None:
225
+ if e.kind == "text":
226
+ box = slide.shapes.add_textbox(_emu(e.x), _emu(e.y), _emu(e.w), _emu(e.h))
227
+ tf = box.text_frame
228
+ tf.word_wrap = True
229
+ tf.auto_size = MSO_AUTO_SIZE.NONE
230
+ tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
231
+ tf.vertical_anchor = {"middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}.get(e.valign, MSO_ANCHOR.TOP)
232
+ for i, runs in enumerate(e.paras):
233
+ p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
234
+ p.alignment = {"center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(e.align, PP_ALIGN.LEFT)
235
+ p.line_spacing = e.lh * 0.96
236
+ if i and e.gap:
237
+ p.space_before = Pt(e.gap * 0.5)
238
+ for r in runs:
239
+ run = p.add_run()
240
+ run.text = r.text
241
+ f = run.font
242
+ f.name = FONTS[e.font]
243
+ f.size = Pt(e.size * 0.5)
244
+ f.bold = r.bold or e.font == "display"
245
+ f.color.rgb = _rgb(color(pal, r.color))
246
+ rpr = run._r.get_or_add_rPr()
247
+ if e.upper:
248
+ rpr.set("cap", "all")
249
+ if e.track:
250
+ rpr.set("spc", str(int(e.track * e.size * 0.5 * 100)))
251
+ return
252
+ if e.kind == "line":
253
+ horizontal = e.h == 0
254
+ x2, y2 = (e.x + e.w, e.y) if horizontal else (e.x, e.y + e.h)
255
+ ln = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, _emu(e.x), _emu(e.y), _emu(x2), _emu(y2))
256
+ _no_style(ln)
257
+ ln.line.color.rgb = _rgb(color(pal, e.stroke))
258
+ ln.line.width = _emu(e.stroke_w)
259
+ return
260
+ kind = MSO_SHAPE.OVAL if e.kind == "circle" else (MSO_SHAPE.ROUNDED_RECTANGLE if e.radius else MSO_SHAPE.RECTANGLE)
261
+ sh = slide.shapes.add_shape(kind, _emu(e.x), _emu(e.y), _emu(e.w), _emu(e.h))
262
+ _no_style(sh)
263
+ if kind == MSO_SHAPE.ROUNDED_RECTANGLE:
264
+ sh.adjustments[0] = min(0.5, e.radius / max(min(e.w, e.h), 1))
265
+ if e.fill:
266
+ sh.fill.solid()
267
+ sh.fill.fore_color.rgb = _rgb(color(pal, e.fill))
268
+ _alpha(sh.fill._xPr.find(qn("a:solidFill")), e.opacity)
269
+ else:
270
+ sh.fill.background()
271
+ if e.stroke and e.stroke_w:
272
+ sh.line.color.rgb = _rgb(color(pal, e.stroke))
273
+ sh.line.width = _emu(e.stroke_w)
274
+ else:
275
+ sh.line.fill.background()
276
+
277
+
278
+ def _notes(slide_data: dict) -> str:
279
+ notes = slide_data.get("notes") or ""
280
+ srcs = [f"{s.get('title') or s['path']} (lines {s['line_start']}-{s['line_end']})" for s in slide_data.get("sources") or []]
281
+ return notes + (("\n\nSources: " + "; ".join(dict.fromkeys(srcs))) if srcs else "")
282
+
283
+
284
+ def build_pptx(deck: dict, theme: str, fmt: str, seed: int) -> bytes:
285
+ """The deck as an editable PowerPoint: text boxes you can type in, over each slide's drawn background."""
286
+ theme = theme_id(theme)
287
+
288
+ def make() -> bytes:
289
+ t = THEMES[theme]
290
+ prs = Presentation()
291
+ prs.slide_width, prs.slide_height = _emu(W), _emu(H)
292
+ blank = prs.slide_layouts[6]
293
+ for i in range(len(deck["slides"])):
294
+ c = compose(deck, i, theme, fmt, seed)
295
+ slide = prs.slides.add_slide(blank)
296
+ d = build(theme, c.slide["layout"], c.slide["variant"], seed, i)
297
+ slide.shapes.add_picture(io.BytesIO(background(d, t.pal, t.dark)), 0, 0, _emu(W), _emu(H))
298
+ for e in c.elements:
299
+ _add_el(slide, e, t.pal)
300
+ note = _notes(c.slide)
301
+ if note:
302
+ slide.notes_slide.notes_text_frame.text = note
303
+ prs.core_properties.title = deck.get("title") or "Slide deck"
304
+ buf = io.BytesIO()
305
+ prs.save(buf)
306
+ return buf.getvalue()
307
+
308
+ return _cached(_key("pptx", deck, theme, fmt, seed), make)
backend/app/slides/layout.py ADDED
@@ -0,0 +1,730 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Dark space: cinematic, centred openers and closers, asymmetric splits. Light space: editorial, left aligned, top
22
+ # 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(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(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
+
223
+
224
+ def _lines(runs: list[Run], width: float, size: float, font: str, upper: bool, track: float) -> int:
225
+ """Lines a paragraph wraps to in `width` pixels."""
226
+ words: list[tuple[str, bool]] = []
227
+ for r in runs:
228
+ t = r.text.upper() if upper else r.text
229
+ words += [(w, r.bold) for w in t.split(" ") if w]
230
+ if not words:
231
+ return 0
232
+ space = (_char_w(" ", font, False) + track) * size
233
+ lines, x = 1, 0.0
234
+ for word, bold in words:
235
+ ww = sum((_char_w(c, font, bold) + track) * size for c in word)
236
+ if ww > width: # a word longer than the line breaks anywhere
237
+ extra = x + (space if x else 0) + ww
238
+ lines += int(extra // width)
239
+ x = extra % width
240
+ continue
241
+ need = ww if x == 0 else x + space + ww
242
+ if need <= width:
243
+ x = need
244
+ else:
245
+ lines += 1
246
+ x = ww
247
+ return lines
248
+
249
+
250
+ # --- placed elements ------------------------------------------------------------------------------------------------
251
+
252
+ @dataclass
253
+ class El:
254
+ kind: str # text | rect | circle | line
255
+ role: str
256
+ x: float
257
+ y: float
258
+ w: float
259
+ h: float
260
+ paras: list[list[Run]] = field(default_factory=list)
261
+ size: float = 0
262
+ lh: float = 1.3
263
+ font: str = "body" # display | body | mono
264
+ align: str = "left"
265
+ valign: str = "top"
266
+ upper: bool = False
267
+ track: float = 0.0 # letter spacing in em
268
+ gap: float = 0.0 # space between paragraphs, in px
269
+ fill: str | None = None # palette key
270
+ stroke: str | None = None
271
+ stroke_w: float = 0
272
+ radius: float = 0
273
+ opacity: float = 1.0
274
+
275
+ def text_height(self) -> float:
276
+ return text_height(self.paras, self.w, self.size, self.lh, self.font, self.upper, self.track, self.gap)
277
+
278
+
279
+ def text_height(paras: list[list[Run]], width: float, size: float, lh: float, font: str, upper: bool = False,
280
+ track: float = 0.0, gap: float = 0.0) -> float:
281
+ n = [_lines(p, width, size, font, upper, track) for p in paras]
282
+ used = [k for k in n if k]
283
+ return sum(k * size * lh for k in used) + max(len(used) - 1, 0) * gap
284
+
285
+
286
+ SCALES = (1.0, 0.92, 0.85, 0.78, 0.72, 0.66, 0.6, 0.55)
287
+ 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
288
+
289
+
290
+ def _scales(start: int, grow: bool = False) -> tuple[float, ...]:
291
+ """Scales to try, largest first: from `start` down, with the growing ones first when allowed and not shrunk."""
292
+ return (GROW + SCALES) if grow and start == 0 else SCALES[start:]
293
+
294
+ # Base font sizes in px, by format. Presenter slides carry fewer words, so they are drawn larger.
295
+ TYPE = {
296
+ "detailed": {"kicker": 22, "heading": 60, "title": 100, "section": 80, "lead": 30, "title_lead": 34, "point": 28,
297
+ "card_term": 30, "card_text": 25, "value": 220, "label": 34, "quote": 52, "by": 24, "take": 28,
298
+ "closing": 46, "closing_big": 66, "agenda": 32, "col_label": 28, "col_item": 27, "num": 22,
299
+ "footer": 18},
300
+ "presenter": {"kicker": 24, "heading": 72, "title": 112, "section": 92, "lead": 36, "title_lead": 38,
301
+ "point": 40, "card_term": 34, "card_text": 34, "value": 260, "label": 40, "quote": 60, "by": 26,
302
+ "take": 36, "closing": 56, "closing_big": 78, "agenda": 36, "col_label": 30, "col_item": 36,
303
+ "num": 24, "footer": 18},
304
+ }
305
+
306
+
307
+ def _fit_size(paras: list[list[Run]], width: float, height: float, base: float, lh: float, font: str,
308
+ start: int, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, grow: bool = False) -> float | None:
309
+ """The largest size (base times a scale, from `start` down) at which the paragraphs fit the box, or None."""
310
+ for scale in _scales(start, grow):
311
+ size = round(base * scale, 1)
312
+ if text_height(paras, width, size, lh, font, upper, track, size * gap_em) <= height:
313
+ return size
314
+ return None
315
+
316
+
317
+ class NoFit(Exception):
318
+ """Text that does not fit even at the smallest size: the caller shortens it and tries again."""
319
+
320
+ def __init__(self, field_name: str, index: int | None = None):
321
+ super().__init__(field_name)
322
+ self.field_name = field_name
323
+ self.index = index
324
+
325
+
326
+ @dataclass
327
+ class Composed:
328
+ slide: dict # the slide as drawn (text shortened when it had to be)
329
+ elements: list[El]
330
+ seed: int
331
+
332
+
333
+ def _text(role: str, rect: tuple, paras: list[list[Run]], base: float, start: int, *, font: str = "body",
334
+ lh: float = 1.35, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, field_name: str = "",
335
+ index: int | None = None, grow: bool = False) -> El:
336
+ x, y, w, h, align, valign = rect[:6]
337
+ size = _fit_size(paras, w, h, base, lh, font, start, upper, track, gap_em, grow)
338
+ if size is None:
339
+ raise NoFit(field_name or role, index)
340
+ return El("text", role, x, y, w, h, paras=paras, size=size, lh=lh, font=font, align=align, valign=valign,
341
+ upper=upper, track=track, gap=size * gap_em)
342
+
343
+
344
+ def _point_runs(p: dict, fmt: str) -> list[Run]:
345
+ if p["term"] and fmt == "detailed":
346
+ return [Run(p["term"] + ".", True, "ink"), Run(" " + p["text"], False, "mute")]
347
+ return [Run(p["text"], False, "ink")]
348
+
349
+
350
+ def _body(els: list[El], s: dict, rect: tuple, kind: str, fmt: str, t: dict, start: int, dark: bool) -> None:
351
+ """The slide's main content: points, agenda items or takeaways, drawn as `kind`."""
352
+ x, y, w, h, _align, valign, _ = rect
353
+ pts = s["points"]
354
+
355
+ if kind in ("list", "list2", "agenda_list", "agenda_grid", "take_stack"):
356
+ if kind in ("agenda_list", "agenda_grid"):
357
+ items = [[Run(p["text"], False, "ink")] for p in pts]
358
+ base, numbered = t["agenda"], True
359
+ elif kind == "take_stack":
360
+ items = [[Run(tx, False, "ink")] for tx in s["takeaways"]]
361
+ base, numbered = t["take"], True
362
+ else:
363
+ items = [_point_runs(p, fmt) for p in pts]
364
+ base, numbered = t["point"], False
365
+ cols = 2 if kind in ("list2", "agenda_grid") and len(items) >= 3 else 1
366
+ per = math.ceil(len(items) / cols)
367
+ col_gap = 70
368
+ cw = (w - col_gap * (cols - 1)) / cols
369
+ indent = 78 if numbered else 44
370
+ tw = cw - indent
371
+ # One size for every item: the largest at which the tallest column fits with breathing room between items.
372
+ size = None
373
+ for scale in _scales(start, True):
374
+ sz = round(base * scale, 1)
375
+ gap = sz * 0.9
376
+ heights = [text_height([it], tw, sz, 1.38, "body") for it in items]
377
+ tallest = max(sum(heights[c * per:(c + 1) * per]) + gap * (len(heights[c * per:(c + 1) * per]) - 1)
378
+ for c in range(cols))
379
+ if tallest <= h:
380
+ size = sz
381
+ break
382
+ if size is None:
383
+ raise NoFit("takeaways" if kind == "take_stack" else "points")
384
+ heights = [text_height([it], tw, size, 1.38, "body") for it in items]
385
+ for c in range(cols):
386
+ chunk = list(range(c * per, min((c + 1) * per, len(items))))
387
+ total = sum(heights[i] for i in chunk)
388
+ free = h - total
389
+ gap = min(free / max(len(chunk) - 1, 1), size * 2.2) if len(chunk) > 1 else 0
390
+ block_h = total + gap * max(len(chunk) - 1, 0)
391
+ cy = y + (h - block_h) / 2 if valign == "middle" else y
392
+ cx = x + c * (cw + col_gap)
393
+ for i in chunk:
394
+ ih = heights[i]
395
+ if numbered:
396
+ els.append(El("text", "num", cx, cy + size * 0.12, 64, size * 1.3, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
397
+ size=round(size * 0.82, 1), lh=1.2, font="mono"))
398
+ else:
399
+ d = size * 0.34
400
+ els.append(El("circle", "dot", cx + 4, cy + size * 0.69 - d / 2, d, d, fill="accent"))
401
+ els.append(El("text", "item", cx + indent, cy, tw, ih + 2, paras=[items[i]], size=size, lh=1.38))
402
+ if numbered and kind != "take_stack" and i != chunk[-1]:
403
+ els.append(El("line", "rule", cx + indent, cy + ih + gap / 2, tw, 0, stroke="line", stroke_w=1.5))
404
+ cy += ih + gap
405
+ return
406
+
407
+ if kind in ("cards", "take_cols", "take_row", "take_arc"):
408
+ if kind == "cards":
409
+ cells = [(p["term"] if fmt == "detailed" else "", p["text"]) for p in pts]
410
+ else:
411
+ cells = [("", tx) for tx in s["takeaways"]]
412
+ n = max(len(cells), 1)
413
+ gap = 36 if kind != "take_row" else 60
414
+ cw = (w - gap * (n - 1)) / n
415
+ boxed = kind in ("cards", "take_cols")
416
+ pad = 40 if boxed else 0
417
+ top = 0.0
418
+ if kind == "take_arc":
419
+ top = 120 # the glowing marker above each takeaway
420
+ elif kind == "take_row":
421
+ top = 70
422
+ else:
423
+ top = 64 # number row inside the card
424
+ inner_w = cw - 2 * pad
425
+ inner_h = h - 2 * pad - top
426
+ term_base = t["card_term"]
427
+ text_base = t["card_text"] if kind == "cards" else t["take"]
428
+ size = None
429
+ for scale in _scales(start, True):
430
+ ts, xs = round(term_base * scale, 1), round(text_base * scale, 1)
431
+ ok = True
432
+ for term, text in cells:
433
+ hh = (text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0)
434
+ hh += text_height([[Run(text)]], inner_w, xs, 1.4, "body")
435
+ if hh > inner_h:
436
+ ok = False
437
+ break
438
+ if ok:
439
+ size = (ts, xs)
440
+ break
441
+ if size is None:
442
+ raise NoFit("takeaways" if kind != "cards" else "points")
443
+ ts, xs = size
444
+ # Cards as tall as the tallest content (not the whole box), so short text does not float in a tall card.
445
+ tallest = max((text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0)
446
+ + text_height([[Run(text)]], inner_w, xs, 1.4, "body") for term, text in cells)
447
+ h = min(h, tallest + 2 * pad + top + 12)
448
+ for i, (term, text) in enumerate(cells):
449
+ cx = x + i * (cw + gap)
450
+ if boxed:
451
+ els.append(El("rect", "card", cx, y, cw, h, fill="card", stroke="cardline", stroke_w=2, radius=22))
452
+ if kind == "take_arc":
453
+ r = 46
454
+ 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))
455
+ els.append(El("circle", "marker", cx + cw / 2 - r, y + 2, r * 2, r * 2, fill="accent2"))
456
+ els.append(El("text", "num", cx + cw / 2 - r, y + 2, r * 2, r * 2, paras=[[Run(f"{i + 1}", True, "white")]],
457
+ size=34, lh=1.0, font="display", align="center", valign="middle"))
458
+ elif kind == "take_row":
459
+ els.append(El("rect", "bar", cx, y, cw, 5, fill="accent"))
460
+ els.append(El("text", "num", cx, y + 20, cw, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
461
+ size=24, lh=1.2, font="mono"))
462
+ else:
463
+ els.append(El("text", "num", cx + pad, y + pad, inner_w, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
464
+ size=t["num"], lh=1.2, font="mono"))
465
+ cy = y + pad + top
466
+ align = "center" if kind == "take_arc" else "left"
467
+ if term:
468
+ th = text_height([[Run(term, True)]], inner_w, ts, 1.15, "display")
469
+ els.append(El("text", "term", cx + pad, cy, inner_w, th + 2, paras=[[Run(term, True, "ink")]], size=ts,
470
+ lh=1.15, font="display", align=align))
471
+ cy += th + ts * 0.5
472
+ els.append(El("text", "item", cx + pad, cy, inner_w, y + h - pad - cy, paras=[[Run(text, False, "mute" if term else "ink")]],
473
+ size=xs, lh=1.4, align=align))
474
+ return
475
+
476
+ if kind in ("timeline_v", "timeline_h"):
477
+ items = [_point_runs(p, fmt) for p in pts]
478
+ n = len(items)
479
+ r = 28
480
+ if kind == "timeline_v":
481
+ tw = w - 96
482
+ size = None
483
+ for scale in _scales(start, True):
484
+ sz = round(t["point"] * scale, 1)
485
+ heights = [max(text_height([it], tw, sz, 1.38, "body"), r * 2) for it in items]
486
+ if sum(heights) + sz * 1.2 * (n - 1) <= h:
487
+ size = sz
488
+ break
489
+ if size is None:
490
+ raise NoFit("points")
491
+ heights = [max(text_height([it], tw, size, 1.38, "body"), r * 2) for it in items]
492
+ gap = min((h - sum(heights)) / max(n - 1, 1), size * 2.4)
493
+ total = sum(heights) + gap * (n - 1)
494
+ cy = y + (h - total) / 2 if valign == "middle" else y
495
+ els.append(El("line", "spine", x + r, cy + r, 0, total - 2 * r if total > 2 * r else 0, stroke="line", stroke_w=3))
496
+ for i, it in enumerate(items):
497
+ els.append(El("circle", "marker", x, cy, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3))
498
+ els.append(El("text", "num", x, cy, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0,
499
+ font="display", align="center", valign="middle"))
500
+ els.append(El("text", "item", x + 96, cy + max((r * 2 - size * 1.38) / 2, 0) if heights[i] <= r * 2 else cy,
501
+ tw, heights[i] + 2, paras=[it], size=size, lh=1.38))
502
+ cy += heights[i] + gap
503
+ else:
504
+ gap = 40
505
+ cw = (w - gap * (n - 1)) / n
506
+ th = h - r * 2 - 40
507
+ size = _fit_all([[it] for it in items], cw, th, t["point"] * 0.9, 1.38, start)
508
+ if size is None:
509
+ raise NoFit("points")
510
+ els.append(El("line", "spine", x + r, y + r, w - 2 * r, 0, stroke="line", stroke_w=3))
511
+ for i, it in enumerate(items):
512
+ cx = x + i * (cw + gap)
513
+ els.append(El("circle", "marker", cx, y, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3))
514
+ els.append(El("text", "num", cx, y, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0,
515
+ font="display", align="center", valign="middle"))
516
+ els.append(El("text", "item", cx, y + r * 2 + 40, cw, th, paras=[it], size=size, lh=1.38))
517
+ return
518
+ raise ValueError(f"unknown body kind {kind}")
519
+
520
+
521
+ def _fit_all(blocks: list[list[list[Run]]], width: float, height: float, base: float, lh: float, start: int) -> float | None:
522
+ for scale in _scales(start, True):
523
+ size = round(base * scale, 1)
524
+ if all(text_height(b, width, size, lh, "body") <= height for b in blocks):
525
+ return size
526
+ return None
527
+
528
+
529
+ def _columns(els: list[El], s: dict, rects: tuple[tuple, tuple], t: dict, start: int) -> None:
530
+ """The two sides of a comparison, at one font size, each panel as tall as the taller side's content."""
531
+ kind = rects[0][6]
532
+ pad = 44 if kind == "panel" else 0
533
+ top = 0 if kind == "panel" else 28
534
+ sides = [s["left"], s["right"]]
535
+
536
+ def measure(col: dict, iw: float, ls: float, xs: float) -> tuple[float, list[float]]:
537
+ label = [[Run(col["label"], True, "accent")]] if col["label"] else []
538
+ lab = text_height(label, iw, ls, 1.2, "display") + (xs * 0.9 if label else 0)
539
+ heights = [text_height([[Run(i)]], iw - 40, xs, 1.38, "body") for i in col["items"]]
540
+ return lab + sum(heights) + xs * 0.75 * (len(heights) - 1), heights
541
+
542
+ for scale in _scales(start, True):
543
+ ls, xs = round(t["col_label"] * scale, 1), round(t["col_item"] * scale, 1)
544
+ need = [measure(col, r[2] - 2 * pad, ls, xs)[0] for col, r in zip(sides, rects, strict=True)]
545
+ bad = [i for i, (n, r) in enumerate(zip(need, rects, strict=True)) if n > r[3] - 2 * pad - top]
546
+ if not bad:
547
+ break
548
+ else:
549
+ raise NoFit(("left", "right")[bad[0]] + "_items")
550
+ panel_h = max(need) + 2 * pad + top + 8
551
+ for col, (x, y, w, h, *_) in zip(sides, rects, strict=True):
552
+ if kind == "panel":
553
+ els.append(El("rect", "panel", x, y, w, min(h, panel_h), fill="card", stroke="cardline", stroke_w=2, radius=22))
554
+ else:
555
+ els.append(El("rect", "bar", x, y, 72, 5, fill="accent"))
556
+ ix, iw = x + pad, w - 2 * pad
557
+ cy = y + pad + top
558
+ _, heights = measure(col, iw, ls, xs)
559
+ if col["label"]:
560
+ lab_h = text_height([[Run(col["label"], True)]], iw, ls, 1.2, "display")
561
+ els.append(El("text", "col_label", ix, cy, iw, lab_h + 2, paras=[[Run(col["label"], True, "accent")]],
562
+ size=ls, lh=1.2, font="display"))
563
+ cy += lab_h + xs * 0.9
564
+ for item, hh in zip(col["items"], heights, strict=True):
565
+ d = xs * 0.32
566
+ els.append(El("circle", "dot", ix + 2, cy + xs * 0.69 - d / 2, d, d, fill="accent"))
567
+ els.append(El("text", "item", ix + 40, cy, iw - 40, hh + 2, paras=[[Run(item, False, "ink")]], size=xs, lh=1.38))
568
+ cy += hh + xs * 0.75
569
+
570
+
571
+ def _compose_once(s: dict, deck: dict, index: int, theme: str, fmt: str, start: int) -> list[El]:
572
+ from app.slides.themes import THEMES
573
+
574
+ t = TYPE[fmt]
575
+ dark = THEMES[theme].dark
576
+ layout, v = s["layout"], s["variant"]
577
+ els: list[El] = []
578
+
579
+ lead_slot = block(theme, layout, v, "lead")
580
+
581
+ def rect(name: str) -> tuple | None:
582
+ r = block(theme, layout, v, name)
583
+ # No lead: what sits right under the lead's slot (in the same column) moves up into it.
584
+ if r and name in ("body", "left", "right") and lead_slot and not s["lead"]:
585
+ lx, ly, lw, lh_ = lead_slot[:4]
586
+ if r[1] >= ly + lh_ - 2 and r[1] - (ly + lh_) < 80 and r[0] < lx + lw and lx < r[0] + r[2]:
587
+ return (r[0], ly, r[2], r[3] + r[1] - ly, *r[4:])
588
+ return r
589
+
590
+ k = rect("kicker")
591
+ if k and s["kicker"]:
592
+ x, y, w, h, align, *_ = k
593
+ bar_x = x + (w - 64) / 2 if align == "center" else (x + w - 64 if align == "right" else x)
594
+ els.append(El("rect", "bar", bar_x, y - 18, 64, 5, fill="accent"))
595
+ els.append(_text("kicker", k, [[Run(s["kicker"], True, "accent")]], t["kicker"], 0, font="mono", lh=1.2,
596
+ upper=True, track=0.16, field_name="kicker"))
597
+
598
+ heading_base = {"title": t["title"], "section": t["section"]}.get(layout, t["heading"])
599
+ hr = rect("heading")
600
+ if hr and s["heading"]:
601
+ els.append(_text("heading", hr, [[Run(s["heading"], True, "ink")]], heading_base, start, font="display",
602
+ lh=1.08, field_name="heading"))
603
+
604
+ lr = rect("lead")
605
+ if lr and s["lead"]:
606
+ base = t["title_lead"] if layout == "title" else t["lead"]
607
+ head = next((e for e in els if e.role == "heading"), None)
608
+ if head and head.valign == "top" and abs(head.x - lr[0]) < 1 and head.y < lr[1]:
609
+ # Under a top aligned heading in the same column: follow the heading instead of waiting at the box's top.
610
+ top = head.y + head.text_height() + head.size * 0.55
611
+ lr = (lr[0], top, lr[2], lr[1] + lr[3] - top, *lr[4:])
612
+ els.append(_text("lead", lr, [[Run(s["lead"], False, "mute")]], base, start, lh=1.4, field_name="lead"))
613
+
614
+ if layout == "stat":
615
+ els.append(_text("value", rect("value"), [[Run(s["stat"]["value"], True, "accent")]], t["value"], start,
616
+ font="display", lh=1.0, field_name="stat_value"))
617
+ els.append(_text("label", rect("label"), [[Run(s["stat"]["label"], False, "ink")]], t["label"], start, lh=1.35,
618
+ field_name="stat_label", grow=True))
619
+ if layout == "quote":
620
+ q = rect("quote")
621
+ els.append(_text("quote", q, [[Run("“", True, "accent"), Run(s["quote"]["text"], False, "ink"),
622
+ Run("”", True, "accent")]], t["quote"], start, font="display", lh=1.22,
623
+ field_name="quote", grow=True))
624
+ if s["quote"]["by"] and (b := rect("by")):
625
+ els.append(_text("by", b, [[Run(s["quote"]["by"], False, "mute")]], t["by"], 0, font="mono", lh=1.3,
626
+ upper=True, track=0.08, field_name="by"))
627
+ if layout == "two_column":
628
+ _columns(els, s, (rect("left"), rect("right")), t, start)
629
+ br = rect("body")
630
+ if br:
631
+ _body(els, s, br, br[6], fmt, t, start, dark)
632
+ cr = rect("closing")
633
+ if cr and s["closing"]:
634
+ kind = cr[6]
635
+ if kind == "band":
636
+ x, y, w, h, *_ = cr
637
+ els.append(El("rect", "band", x, y, w, h, fill="band", radius=24))
638
+ inner = (x + 60, y, w - 120, h, "left", "middle")
639
+ els.append(_text("closing", inner, [[Run(s["closing"], True, "bandink")]], t["closing"], start,
640
+ font="display", lh=1.15, field_name="closing"))
641
+ else:
642
+ base = t["closing_big"] if kind == "big" else t["closing"]
643
+ els.append(_text("closing", cr, [[Run(s["closing"], True, "ink")]], base, start, font="display", lh=1.12,
644
+ field_name="closing", grow=kind == "big"))
645
+
646
+ if layout != "title":
647
+ total = len(deck["slides"])
648
+ foot = [[Run(clip(deck.get("title"), 70), False, "mute")]]
649
+ els.append(El("text", "footer", 140, 1018, 1200, 30, paras=foot, size=t["footer"], lh=1.2, font="mono"))
650
+ els.append(El("text", "footer", 1480, 1018, 300, 30, paras=[[Run(f"{index + 1:02d} / {total:02d}", False, "mute")]],
651
+ size=t["footer"], lh=1.2, font="mono", align="right"))
652
+ return els
653
+
654
+
655
+ def _shorten(s: dict, err: NoFit, fmt: str) -> bool:
656
+ """Make the text that did not fit shorter (or drop a point). False when there is nothing left to shorten."""
657
+ lo = LIMITS[fmt]["points"][0]
658
+ name = err.field_name
659
+
660
+ def cut(text: str) -> str:
661
+ return clip(text, max(int(len(text) * 0.82), 12)) if len(text) > 14 else text
662
+
663
+ if name == "points":
664
+ pts = s["points"]
665
+ if s["layout"] == "agenda" or len(pts) > max(lo, 3):
666
+ if len(pts) > 2:
667
+ pts.pop()
668
+ return True
669
+ longest = max(range(len(pts)), key=lambda i: len(pts[i]["text"]) + len(pts[i]["term"]), default=None)
670
+ if longest is None or len(pts[longest]["text"]) <= 14:
671
+ return False
672
+ pts[longest]["text"] = cut(pts[longest]["text"])
673
+ return True
674
+ if name == "takeaways":
675
+ tk = s["takeaways"]
676
+ i = max(range(len(tk)), key=lambda j: len(tk[j]), default=None)
677
+ if i is None or len(tk[i]) <= 14:
678
+ return False
679
+ tk[i] = cut(tk[i])
680
+ return True
681
+ if name in ("left_items", "right_items"):
682
+ col = s[name.split("_")[0]]
683
+ if len(col["items"]) > 2:
684
+ col["items"].pop()
685
+ return True
686
+ i = max(range(len(col["items"])), key=lambda j: len(col["items"][j]), default=None)
687
+ if i is None or len(col["items"][i]) <= 14:
688
+ return False
689
+ col["items"][i] = cut(col["items"][i])
690
+ return True
691
+ path = {"stat_value": ("stat", "value"), "stat_label": ("stat", "label"), "quote": ("quote", "text"),
692
+ "by": ("quote", "by")}.get(name)
693
+ if path:
694
+ holder, key = s[path[0]], path[1]
695
+ elif name in s and isinstance(s[name], str):
696
+ holder, key = s, name
697
+ else:
698
+ return False
699
+ if len(holder[key]) <= 14:
700
+ if name in ("lead", "by", "kicker"):
701
+ holder[key] = ""
702
+ return True
703
+ return False
704
+ holder[key] = cut(holder[key])
705
+ return True
706
+
707
+
708
+ def compose(deck: dict, index: int, theme: str, fmt: str, seed: int = 0) -> Composed:
709
+ """Slide `index` of a clamped deck, placed. Text that does not fit is shortened until it does; a slide that
710
+ still cannot be drawn becomes a plain section slide with its heading."""
711
+ theme, fmt = theme_id(theme), fmt if fmt in TYPE else "detailed"
712
+ s = copy.deepcopy(deck["slides"][index])
713
+ if s["variant"] not in VARIANTS[s["layout"]]:
714
+ s["variant"] = VARIANTS[s["layout"]][0]
715
+ start = min(int(s.get("shrink") or 0), len(SCALES) - 1)
716
+ for _ in range(60):
717
+ try:
718
+ return Composed(s, _compose_once(s, deck, index, theme, fmt, start), seed)
719
+ except NoFit as err:
720
+ if not _shorten(s, err, fmt):
721
+ break
722
+ plain = {**copy.deepcopy(s), "layout": "section", "variant": VARIANTS["section"][0], "lead": "", "points": [],
723
+ "heading": clip(s["heading"] or deck.get("title"), 60)}
724
+ return Composed(plain, _compose_once(plain, deck, index, theme, fmt, 0), seed)
725
+
726
+
727
+ def seed_of(value: object) -> int:
728
+ """A stable integer seed from a deck id."""
729
+ digits = re.sub(r"[^0-9a-f]", "", str(value or "").lower())
730
+ 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,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from markupsafe import escape
5
+
6
+ from app.slides.decor import Decor, Shape, build
7
+ from app.slides.layout import Composed, El, compose
8
+ from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
9
+
10
+ FONT_LINK = ("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;700"
11
+ "&family=JetBrains+Mono:wght@400;700&display=swap")
12
+
13
+ _FAMILY = {"display": f"'{FONTS['display']}','{FONTS['body']}',sans-serif", "body": f"'{FONTS['body']}',sans-serif",
14
+ "mono": f"'{FONTS['mono']}',monospace"}
15
+
16
+
17
+ def color(pal: Palette, key: str | None) -> str:
18
+ if not key:
19
+ return "transparent"
20
+ return "#ffffff" if key == "white" else getattr(pal, key)
21
+
22
+
23
+ CSS = f"""
24
+ html,body{{margin:0;padding:0}}
25
+ .slide{{position:relative;width:{W}px;height:{H}px;overflow:hidden;font-family:{_FAMILY['body']};
26
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}}
27
+ .slide svg.bg{{position:absolute;inset:0}}
28
+ .e{{position:absolute;box-sizing:border-box;margin:0}}
29
+ .t{{display:flex;flex-direction:column;overflow:visible;overflow-wrap:anywhere}}
30
+ .t>.in{{display:block}}
31
+ .t.v-middle{{justify-content:center}}.t.v-bottom{{justify-content:flex-end}}
32
+ .t p{{margin:0}}
33
+ .t b{{font-weight:700}}
34
+ .r-heading,.r-quote,.r-closing,.r-term{{text-wrap:balance}}
35
+ .dark .r-heading.l-title,.dark .r-value{{text-shadow:0 0 48px rgba(33,124,255,.45)}}
36
+ .dark .r-marker{{box-shadow:0 0 36px rgba(33,124,255,.75)}}
37
+ .dark .r-bar{{box-shadow:0 0 18px rgba(77,148,255,.8)}}
38
+ .light .r-card,.light .r-panel{{box-shadow:0 18px 44px rgba(11,26,58,.08)}}
39
+ .light .r-band{{box-shadow:0 18px 44px rgba(11,26,58,.18)}}
40
+ """
41
+
42
+
43
+ def _style(**kw) -> str:
44
+ return ";".join(f"{k.replace('_', '-')}:{v}" for k, v in kw.items() if v is not None)
45
+
46
+
47
+ def _el(e: El, pal: Palette, layout: str) -> str:
48
+ 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")
49
+ if e.kind == "text":
50
+ paras = []
51
+ for i, p in enumerate(e.paras):
52
+ runs = "".join(
53
+ f'<span style="color:{color(pal, r.color)}{";font-weight:700" if r.bold else ""}">{escape(r.text)}</span>'
54
+ for r in p)
55
+ gap = f' style="margin-top:{e.gap:.1f}px"' if i and e.gap else ""
56
+ paras.append(f"<p{gap}>{runs}</p>")
57
+ css = _style(**box, font_size=f"{e.size}px", line_height=str(e.lh), font_family=_FAMILY[e.font],
58
+ text_align=e.align, letter_spacing=f"{e.track}em" if e.track else None,
59
+ text_transform="uppercase" if e.upper else None,
60
+ font_weight="700" if e.font == "display" else None)
61
+ return f'<div class="e t r-{e.role} l-{layout} v-{e.valign}" style="{css}"><div class="in">{"".join(paras)}</div></div>'
62
+ if e.kind == "line":
63
+ horizontal = e.h == 0
64
+ css = _style(left=f"{e.x:.1f}px", top=f"{e.y - (e.stroke_w / 2 if horizontal else 0):.1f}px",
65
+ width=f"{e.w if horizontal else e.stroke_w:.1f}px", height=f"{e.stroke_w if horizontal else e.h:.1f}px",
66
+ background=color(pal, e.stroke), border_radius="2px")
67
+ return f'<div class="e r-{e.role}" style="{css}"></div>'
68
+ radius = "50%" if e.kind == "circle" else f"{e.radius}px"
69
+ border = f"{e.stroke_w}px solid {color(pal, e.stroke)}" if e.stroke and e.stroke_w else None
70
+ css = _style(**box, background=color(pal, e.fill), border=border, border_radius=radius,
71
+ opacity=str(e.opacity) if e.opacity < 1 else None)
72
+ return f'<div class="e r-{e.role}" style="{css}"></div>'
73
+
74
+
75
+ def _svg(d: Decor, pal: Palette, dark: bool, uid: str) -> str:
76
+ defs, out = [], []
77
+ for n, s in enumerate(d.shapes):
78
+ out.append(_shape(s, pal, dark, f"{uid}{n}", defs))
79
+ hx, hy = d.hot
80
+ sky = (f'<radialGradient id="{uid}sky" gradientUnits="userSpaceOnUse" cx="{hx:.0f}" cy="{hy:.0f}" r="{W * 0.85:.0f}">'
81
+ f'<stop offset="0" stop-color="{pal.bg2}"/><stop offset="1" stop-color="{pal.bg1}"/></radialGradient>')
82
+ return (f'<svg class="bg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><defs>{sky}{"".join(defs)}</defs>'
83
+ f'<rect width="{W}" height="{H}" fill="url(#{uid}sky)"/>{"".join(out)}</svg>')
84
+
85
+
86
+ def _shape(s: Shape, pal: Palette, dark: bool, gid: str, defs: list[str]) -> str:
87
+ c = color(pal, s.color)
88
+ if s.kind == "stars":
89
+ return "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{o}"/>' for x, y, r, o in s.points)
90
+ if s.kind == "glow":
91
+ defs.append(f'<radialGradient id="{gid}"><stop offset="0" stop-color="{c}" stop-opacity="{s.opacity}"/>'
92
+ f'<stop offset="1" stop-color="{c}" stop-opacity="0"/></radialGradient>')
93
+ return f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
94
+ if s.kind == "planet":
95
+ hi, lo, rim = planet_colors(pal, dark)
96
+ defs.append(f'<radialGradient id="{gid}" cx="35%" cy="28%" r="80%"><stop offset="0" stop-color="{hi}"/>'
97
+ f'<stop offset="1" stop-color="{lo}"/></radialGradient>')
98
+ return (f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
99
+ f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="none" stroke="{rim}" stroke-width="3" opacity=".7"/>')
100
+ if s.kind == "ring":
101
+ return (f'<ellipse cx="{s.cx:.0f}" cy="{s.cy:.0f}" rx="{s.rx:.0f}" ry="{s.ry:.0f}" fill="none" stroke="{c}" '
102
+ f'stroke-width="{s.width}" opacity="{s.opacity}" transform="rotate({s.rot:.1f} {s.cx:.0f} {s.cy:.0f})"/>')
103
+ if s.kind == "const":
104
+ lines = "".join(f'<line x1="{s.points[a][0]:.0f}" y1="{s.points[a][1]:.0f}" x2="{s.points[b][0]:.0f}" '
105
+ f'y2="{s.points[b][1]:.0f}" stroke="{c}" stroke-width="1.6" opacity="{s.opacity * 0.5:.2f}"/>'
106
+ for a, b in s.edges)
107
+ dots = "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{s.opacity}"/>' for x, y, r, _ in s.points)
108
+ return lines + dots
109
+ return ""
110
+
111
+
112
+ def planet_colors(pal: Palette, dark: bool) -> tuple[str, str, str]:
113
+ """Lit side, dark side and rim of a planet."""
114
+ return ("#1d4fb3", "#020713", pal.accent) if dark else ("#5d9bff", pal.accent2, pal.accent)
115
+
116
+
117
+ def slide_body(c: Composed, deck: dict, index: int, theme: str) -> str:
118
+ t = THEMES[theme]
119
+ s = c.slide
120
+ d = build(theme, s["layout"], s["variant"], c.seed, index)
121
+ els = "".join(_el(e, t.pal, s["layout"]) for e in c.elements)
122
+ cls = f"slide {'dark' if t.dark else 'light'} l-{s['layout']} v-{s['variant']}"
123
+ return f'<div class="{cls}" style="background:{t.pal.bg1}">{_svg(d, t.pal, t.dark, f"s{index}g")}{els}</div>'
124
+
125
+
126
+ def _page(body: str, extra_css: str = "") -> str:
127
+ return (f'<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="{FONT_LINK}">'
128
+ f"<style>{CSS}{extra_css}</style></head><body>{body}</body></html>")
129
+
130
+
131
+ def slide_html(deck: dict, index: int, theme: str, fmt: str, seed: int) -> str:
132
+ """One slide as a standalone page."""
133
+ theme = theme_id(theme)
134
+ return _page(slide_body(compose(deck, index, theme, fmt, seed), deck, index, theme),
135
+ f"body{{background:{THEMES[theme].pal.bg1}}}")
136
+
137
+
138
+ def deck_print_html(deck: dict, theme: str, fmt: str, seed: int, script: str = "") -> str:
139
+ """Every slide on one page, one per printed page."""
140
+ theme = theme_id(theme)
141
+ body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme) for i in range(len(deck["slides"])))
142
+ css = (f"@page{{size:{W}px {H}px;margin:0}}body{{background:{THEMES[theme].pal.bg1}}}"
143
+ ".slide{break-after:page;page-break-after:always}.slide:last-child{break-after:auto;page-break-after:auto}"
144
+ "*{-webkit-print-color-adjust:exact;print-color-adjust:exact}")
145
+ 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", "Dark space", 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", "Light space", False, Palette(
38
+ bg1="#e9eef9", bg2="#ffffff", ink="#0b1a3a", mute="#4b5a7a", accent="#217cff", accent2="#0b3d91",
39
+ card="#ffffff", cardline="#d3dcef", line="#c6d1ea", 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}]},
@@ -1094,8 +1094,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 +1116,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}]},
 
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["WriteAudioScript"] = {"turns": [{"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
  assert voices == {"host_a": settings.elevenlabs_voice_a, "host_b": settings.elevenlabs_voice_b}
1117
 
1118
 
1119
+ def test_audio_overview_alternates_two_hosts_or_uses_one(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
1120
+ from app.config import settings
1121
+ from app.services import tts
1122
+
1123
+ connect(client, auth, run_jobs)
1124
+ pricing = next(d for d in docs(client, auth) if d["title"] == "On Pricing")
1125
+ nb = client.post("/api/notebooks", json={"title": "Money", "document_ids": [pricing["id"]]}, headers=auth).json()
1126
+ monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
1127
+ used = []
1128
+ monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
1129
+ b"\x00" * 16000)
1130
+ seen = {}
1131
+
1132
+ def script(**kw):
1133
+ seen.update(kw)
1134
+ return {"turns": [ # host A twice in a row, **markup**, an empty turn: cleaned and made to alternate
1135
+ {"speaker": "host_a", "text": "Welcome in.", "sources": []},
1136
+ {"speaker": "host_a", "text": "Today, **pricing**.", "sources": []},
1137
+ {"speaker": "host_b", "text": "", "sources": []},
1138
+ {"speaker": "host_b", "text": "Prices went up.", "sources": []},
1139
+ {"speaker": "host_b", "text": "And readers stayed.", "sources": []},
1140
+ {"speaker": "host_a", "text": "Exactly.", "sources": []},
1141
+ ]}
1142
+
1143
+ llm["WriteAudioScript"] = script
1144
+ body = {"type": "audio_overview", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "minutes": 3,
1145
+ "format": "critique", "language": "Spanish", "instructions": "- Focus on raising prices"}
1146
+ art = client.post("/api/artifacts/generate", json=body, headers=auth).json()
1147
+ run_jobs()
1148
+ done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
1149
+ assert done["status"] == "ready", done["content"]
1150
+ segs = done["content"]["segments"]
1151
+ assert [(s["speaker"], s["text"]) for s in segs] == [
1152
+ ("host_a", "Welcome in. Today, pricing."), ("host_b", "Prices went up. And readers stayed."), ("host_a", "Exactly.")]
1153
+ assert seen["hosts"] == 2 and seen["target_words"] == 450 and seen["language"] == "Spanish"
1154
+ assert seen["focus"] == "- Focus on raising prices" and seen["style"].startswith("Critique")
1155
+ assert any(pricing["path"] in m for m in seen["material"]) # the picked post is what the script reads
1156
+ voices = done["content"]["voices"]
1157
+ assert used[1][1] == voices["host_b"] != voices["host_a"] == used[0][1]
1158
+
1159
+ used.clear()
1160
+ one = client.post("/api/artifacts/generate", json={**body, "hosts": 1, "format": "brief"}, headers=auth).json()
1161
+ run_jobs()
1162
+ done = client.get(f"/api/artifacts/{one['id']}", headers=auth).json()
1163
+ assert {s["speaker"] for s in done["content"]["segments"]} == {"host_a"} and seen["hosts"] == 1
1164
+ assert list(done["content"]["voices"]) == ["host_a"] and {v for _, v in used} == {done["content"]["voices"]["host_a"]}
1165
+ assert done["content"]["hosts"] == 1 and done["content"]["focus"] == "- Focus on raising prices"
1166
+
1167
+ r = client.post("/api/artifacts/generate", json={**body, "hosts": 1, "format": "debate"}, headers=auth)
1168
+ assert r.status_code == 400 and "two hosts" in r.json()["detail"]
1169
+ assert client.post("/api/artifacts/generate", json={**body, "document_ids": []}, headers=auth).status_code == 400
1170
+
1171
+
1172
  def test_tts_retries_rate_limits(monkeypatch):
1173
  from app.config import settings
1174
  from app.services import tts
backend/tests/test_slides.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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,8 @@ 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 = {
 
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
  };
165
 
166
  export const jobsApi = {
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 = {
 
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 = {
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/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/SlideDeck.tsx ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { artifactsApi, type GenerateBody } from "../api/endpoints";
4
+ import type { Artifact, ChatSummary } from "../api/types";
5
+ import { Dropdown } from "./Dropdown";
6
+ import { useJob } from "../hooks/useJob";
7
+ import { CheckIcon, CollapseIcon, DownloadIcon, ExpandIcon, MoreIcon, PdfIcon, PlayIcon, PptxIcon, SlidesIcon, TrashIcon } from "./icons/Icons";
8
+ import { LANGUAGES, Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
9
+ import { ConfirmDeleteModal, errorMessage, formatDate, JobProgress, Modal } from "./ui";
10
+
11
+ /** The two deck themes. Their slides are laid out on the server (backend/app/slides); the thumbnails are rendered from
12
+ * them (backend/scripts/render_slide_thumbnails.py). */
13
+ export const SLIDE_THEMES = [
14
+ { id: "dark-space", name: "Dark space", blurb: "Night sky, planets and a blue glow" },
15
+ { id: "light-space", name: "Light space", blurb: "Daylight sky, orbits and constellations" },
16
+ ] as const;
17
+
18
+ const FORMATS = [
19
+ { id: "detailed", label: "Detailed deck", blurb: "Full text and details, perfect for emailing or reading on its own." },
20
+ { id: "presenter", label: "Presenter slides", blurb: "Clean slides with key talking points to support you while you speak." },
21
+ ] as const;
22
+
23
+ const LENGTHS = [
24
+ { id: "short", label: "Short", range: "6 to 8 slides" },
25
+ { id: "default", label: "Default", range: "9 to 11 slides" },
26
+ { id: "long", label: "Long", range: "12 to 16 slides" },
27
+ ] as const;
28
+
29
+ const SLIDE_W = 1920;
30
+ const SLIDE_H = 1080;
31
+
32
+ export type SlideDeckRequest = Omit<GenerateBody, "type">;
33
+ type DeckFormat = "detailed" | "presenter";
34
+ type DeckLength = "short" | "default" | "long";
35
+
36
+ /** The slide deck settings: a visual style on top, then the format, language, length, sources and what it should say. */
37
+ export function SlideDeckDialog({ notebookId, notebookTitle, chats, currentChatId, onClose, onCreate }: {
38
+ notebookId: string;
39
+ notebookTitle?: string;
40
+ chats: ChatSummary[];
41
+ currentChatId?: string | null;
42
+ onClose: () => void;
43
+ onCreate: (body: SlideDeckRequest) => void;
44
+ }) {
45
+ const [sel, setSel] = useState<SourceSelection | null>(null);
46
+ const [theme, setTheme] = useState<string>("dark-space");
47
+ const [format, setFormat] = useState<DeckFormat>("detailed");
48
+ const [length, setLength] = useState<DeckLength>("default");
49
+ const [language, setLanguage] = useState("English");
50
+ const [prompt, setPrompt] = useState("");
51
+
52
+ return (
53
+ <Modal title="Customize slide deck" onClose={onClose} wide>
54
+ <div className="stack vw-in-modal sd-dialog">
55
+ <section className="field">
56
+ <span className="vw-label">Visual style</span>
57
+ <div className="sd-themes" role="radiogroup" aria-label="Visual style">
58
+ {SLIDE_THEMES.map((t) => (
59
+ <button key={t.id} type="button" role="radio" aria-checked={theme === t.id} title={t.blurb}
60
+ className={`sd-theme${theme === t.id ? " on" : ""}`} onClick={() => setTheme(t.id)}>
61
+ <span className="sd-theme-img">
62
+ <img src={`/slides/${t.id}.png`} alt="" loading="lazy" />
63
+ {theme === t.id && <span className="sd-check" aria-hidden="true"><CheckIcon size={12} /></span>}
64
+ </span>
65
+ <span className="sd-theme-name">{t.name}</span>
66
+ </button>
67
+ ))}
68
+ </div>
69
+ </section>
70
+
71
+ <section className="sd-opts">
72
+ <div className="field">
73
+ <span className="vw-label">Format</span>
74
+ <Pills<DeckFormat> label="Format" value={format} onChange={setFormat} options={FORMATS} />
75
+ <small className="muted">{FORMATS.find((f) => f.id === format)?.blurb}</small>
76
+ </div>
77
+ <div className="field">
78
+ <span className="vw-label">Length</span>
79
+ <Dropdown<DeckLength> label="Length" value={length} onChange={setLength}
80
+ options={LENGTHS.map((l) => ({ value: l.id, label: l.label, hint: l.range }))} />
81
+ </div>
82
+ <div className="field">
83
+ <span className="vw-label">Language</span>
84
+ <Dropdown<string> label="Language" value={language} onChange={setLanguage}
85
+ options={LANGUAGES.map((l) => ({ value: l, label: l }))} />
86
+ </div>
87
+ </section>
88
+
89
+ <SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
90
+ what="slide deck" noFocus onChange={setSel} />
91
+
92
+ <label className="field">
93
+ <span className="vw-label">Describe the slide deck you want to create <span className="muted">(optional)</span></span>
94
+ <textarea className="input sd-prompt" rows={3} maxLength={4000} value={prompt} onChange={(e) => setPrompt(e.target.value)}
95
+ 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."'} />
96
+ </label>
97
+
98
+ {!sel?.ready && <p className="muted small">Select at least one post or chat to make a slide deck.</p>}
99
+ <div className="vw-nav">
100
+ <button type="button" className="btn btn-primary vw-next" disabled={!sel?.ready}
101
+ onClick={() => sel && onCreate({ ...sel.request, theme, deck_format: format, deck_length: length,
102
+ language, instructions: prompt.trim() })}>
103
+ Generate slide deck
104
+ </button>
105
+ </div>
106
+ </div>
107
+ </Modal>
108
+ );
109
+ }
110
+
111
+ /** One slide (HTML from our own layout, every word escaped) in a sandboxed frame, scaled to fit the box it is given. */
112
+ export function SlideFrame({ html, title, fit = "width", hidden = false }: {
113
+ html: string;
114
+ title: string;
115
+ /** "width": as wide as the box, 16:9. "contain": as large as fits in the box both ways (presenting). */
116
+ fit?: "width" | "contain";
117
+ hidden?: boolean;
118
+ }) {
119
+ const box = useRef<HTMLDivElement>(null);
120
+ const [scale, setScale] = useState(0.2);
121
+ useEffect(() => {
122
+ const el = box.current;
123
+ if (!el) return;
124
+ const measure = () => {
125
+ const s = fit === "contain" ? Math.min(el.clientWidth / SLIDE_W, el.clientHeight / SLIDE_H) : el.clientWidth / SLIDE_W;
126
+ if (s > 0) setScale(s);
127
+ };
128
+ measure();
129
+ if (typeof ResizeObserver === "undefined") return;
130
+ const ro = new ResizeObserver(measure);
131
+ ro.observe(el);
132
+ return () => ro.disconnect();
133
+ }, [fit]);
134
+ return (
135
+ <div ref={box} className={`sd-frame sd-frame-${fit}`} style={hidden ? { visibility: "hidden" } : undefined} aria-hidden={hidden}>
136
+ <iframe title={title} srcDoc={html} sandbox="" tabIndex={-1} width={SLIDE_W} height={SLIDE_H}
137
+ style={{ transform: `translate(-50%, -50%) scale(${scale})` }} />
138
+ </div>
139
+ );
140
+ }
141
+
142
+ type DeckSlide = { layout: string; heading: string; notes?: string; sources?: { title?: string; path: string; line_start: number; line_end: number }[] };
143
+
144
+ function deckOf(artifact: Artifact): { pages: string[]; slides: DeckSlide[] } {
145
+ const c = artifact.content;
146
+ const pages = Array.isArray(c.slides_html) ? (c.slides_html as string[]) : [];
147
+ const slides = Array.isArray(c.deck?.slides) ? (c.deck.slides as DeckSlide[]) : [];
148
+ return { pages, slides };
149
+ }
150
+
151
+ /** Playing the deck: full screen, one slide at a time. Arrows, Space, Page Up/Down, Home and End move; a click goes on;
152
+ * Escape (or leaving full screen) stops. Every slide is loaded at once and only the current one shown, so moving between
153
+ * them never flashes. */
154
+ export function SlidePresenter({ artifact, start = 0, onClose }: { artifact: Artifact; start?: number; onClose: () => void }) {
155
+ const { pages } = deckOf(artifact);
156
+ const [i, setI] = useState(Math.min(start, Math.max(pages.length - 1, 0)));
157
+ const [idle, setIdle] = useState(false);
158
+ const root = useRef<HTMLDivElement>(null);
159
+ const timer = useRef<number | undefined>(undefined);
160
+ const dark = artifact.content.theme !== "light-space";
161
+
162
+ const go = useCallback((to: number) => setI(Math.max(0, Math.min(pages.length - 1, to))), [pages.length]);
163
+
164
+ useEffect(() => {
165
+ const el = root.current;
166
+ // Full screen when the browser allows it (not iOS Safari): the overlay already fills the window either way.
167
+ if (el?.requestFullscreen) el.requestFullscreen().catch(() => undefined);
168
+ const onChange = () => {
169
+ if (!document.fullscreenElement) onClose();
170
+ };
171
+ document.addEventListener("fullscreenchange", onChange);
172
+ return () => {
173
+ document.removeEventListener("fullscreenchange", onChange);
174
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined);
175
+ };
176
+ }, [onClose]);
177
+
178
+ useEffect(() => {
179
+ const onKey = (e: KeyboardEvent) => {
180
+ if (["ArrowRight", "ArrowDown", "PageDown", " ", "Enter"].includes(e.key)) go(i + 1);
181
+ else if (["ArrowLeft", "ArrowUp", "PageUp", "Backspace"].includes(e.key)) go(i - 1);
182
+ else if (e.key === "Home") go(0);
183
+ else if (e.key === "End") go(pages.length - 1);
184
+ else if (e.key === "Escape") onClose();
185
+ else return;
186
+ // Ours alone: the viewer (and its modal) underneath must not move or close too.
187
+ e.preventDefault();
188
+ e.stopImmediatePropagation();
189
+ };
190
+ document.addEventListener("keydown", onKey, true);
191
+ return () => document.removeEventListener("keydown", onKey, true);
192
+ }, [go, i, pages.length, onClose]);
193
+
194
+ const wake = () => {
195
+ setIdle(false);
196
+ window.clearTimeout(timer.current);
197
+ timer.current = window.setTimeout(() => setIdle(true), 2200);
198
+ };
199
+ useEffect(() => {
200
+ wake();
201
+ return () => window.clearTimeout(timer.current);
202
+ }, []);
203
+
204
+ return createPortal(
205
+ <div ref={root} className={`sd-present${dark ? "" : " light"}${idle ? " idle" : ""}`} role="dialog" aria-modal="true"
206
+ aria-label={`${artifact.title}, slide ${i + 1} of ${pages.length}`} onMouseMove={wake}
207
+ onClick={(e) => (e.target as HTMLElement).closest(".sd-present-bar") ? undefined : go(i + 1)}>
208
+ <div className="sd-present-stage">
209
+ {pages.map((html, k) => (
210
+ <SlideFrame key={k} html={html} title={`Slide ${k + 1}`} fit="contain" hidden={k !== i} />
211
+ ))}
212
+ </div>
213
+ <div className="sd-present-bar" role="toolbar" aria-label="Presenting">
214
+ <button type="button" className="btn btn-small" onClick={() => go(i - 1)} disabled={i === 0} aria-label="Previous slide">Prev</button>
215
+ <span className="sd-count" aria-live="polite">{i + 1} / {pages.length}</span>
216
+ <button type="button" className="btn btn-small" onClick={() => go(i + 1)} disabled={i === pages.length - 1} aria-label="Next slide">Next</button>
217
+ <button type="button" className="btn btn-small" onClick={onClose}>Exit</button>
218
+ </div>
219
+ </div>,
220
+ document.body,
221
+ );
222
+ }
223
+
224
+ /** A small menu under an icon button. Closes on a click outside or Escape (an Escape that closes the menu does not
225
+ * also close the popup it sits in). */
226
+ function useMenu() {
227
+ const [open, setOpen] = useState(false);
228
+ const ref = useRef<HTMLDivElement>(null); // the button's wrapper
229
+ const menuRef = useRef<HTMLDivElement>(null); // the floating menu (rendered in <body>)
230
+ useEffect(() => {
231
+ if (!open) return;
232
+ const inside = (t: EventTarget | null) => !!t && (ref.current?.contains(t as Node) || menuRef.current?.contains(t as Node));
233
+ const onDown = (e: MouseEvent) => {
234
+ if (!inside(e.target)) setOpen(false);
235
+ };
236
+ const onKey = (e: KeyboardEvent) => {
237
+ if (e.key !== "Escape") return;
238
+ e.stopImmediatePropagation();
239
+ setOpen(false);
240
+ };
241
+ // The menu floats at a fixed place: scrolling what is under it (not the menu itself) closes it.
242
+ const onScroll = (e: Event) => {
243
+ if (!inside(e.target)) setOpen(false);
244
+ };
245
+ const onResize = () => setOpen(false);
246
+ document.addEventListener("mousedown", onDown);
247
+ document.addEventListener("keydown", onKey, true);
248
+ window.addEventListener("scroll", onScroll, true);
249
+ window.addEventListener("resize", onResize);
250
+ return () => {
251
+ document.removeEventListener("mousedown", onDown);
252
+ document.removeEventListener("keydown", onKey, true);
253
+ window.removeEventListener("scroll", onScroll, true);
254
+ window.removeEventListener("resize", onResize);
255
+ };
256
+ }, [open]);
257
+ return { open, setOpen, ref, menuRef };
258
+ }
259
+
260
+ /** A menu under its button, drawn in <body> so a scrolling list or a popup can never clip it. Opens below and to the
261
+ * left of the button's right edge, and above it when there is no room below. */
262
+ function FloatingMenu({ menu, children }: { menu: ReturnType<typeof useMenu>; children: ReactNode }) {
263
+ const [pos, setPos] = useState<CSSProperties>({ visibility: "hidden" });
264
+ useLayoutEffect(() => {
265
+ const button = menu.ref.current?.getBoundingClientRect();
266
+ const box = menu.menuRef.current?.getBoundingClientRect();
267
+ if (!button || !box) return;
268
+ const below = button.bottom + 6 + box.height <= window.innerHeight - 8;
269
+ setPos({
270
+ top: below ? button.bottom + 6 : Math.max(8, button.top - 6 - box.height),
271
+ left: Math.max(8, Math.min(button.right - box.width, window.innerWidth - box.width - 8)),
272
+ });
273
+ }, [menu.ref, menu.menuRef]);
274
+ return createPortal(
275
+ <div ref={menu.menuRef} className="sd-menu sd-menu-floating" role="menu" style={pos} onClick={(e) => e.stopPropagation()}>
276
+ {children}
277
+ </div>,
278
+ document.body,
279
+ );
280
+ }
281
+
282
+ /** Saves the deck as a PDF (exactly as shown) or an editable PowerPoint, drawn by the server. */
283
+ function useDeckDownload(artifact: Artifact) {
284
+ const [busy, setBusy] = useState<"pdf" | "pptx" | null>(null);
285
+ const [error, setError] = useState<string | null>(null);
286
+ const name = deckTitle(artifact).replace(/[^\w\- ]+/g, "").trim().slice(0, 80) || "Slide deck";
287
+ const save = async (ext: "pdf" | "pptx") => {
288
+ setBusy(ext);
289
+ setError(null);
290
+ try {
291
+ const blob = await artifactsApi.slidesFile(artifact.id, ext);
292
+ const url = URL.createObjectURL(blob);
293
+ const a = document.createElement("a");
294
+ a.href = url;
295
+ a.download = `${name}.${ext}`;
296
+ document.body.appendChild(a);
297
+ a.click();
298
+ a.remove();
299
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
300
+ } catch (e) {
301
+ setError(errorMessage(e));
302
+ } finally {
303
+ setBusy(null);
304
+ }
305
+ };
306
+ return { busy, error, save };
307
+ }
308
+
309
+ function deckTitle(artifact: Artifact): string {
310
+ return artifact.title.replace(/^Slide deck: /, "") || (artifact.content.deck?.title as string | undefined) || "Slide deck";
311
+ }
312
+
313
+ /** "5d ago", "3h ago", "just now". */
314
+ function ago(iso: string | null): string {
315
+ if (!iso) return "";
316
+ const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
317
+ if (s < 60) return "just now";
318
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
319
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
320
+ if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
321
+ return formatDate(iso);
322
+ }
323
+
324
+ /** Download, in the popup's header: a menu of PDF or PowerPoint. */
325
+ function DownloadMenu({ artifact }: { artifact: Artifact }) {
326
+ const menu = useMenu();
327
+ const dl = useDeckDownload(artifact);
328
+ return (
329
+ <div className="sd-menu-wrap" ref={menu.ref}>
330
+ <button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} disabled={dl.busy !== null}
331
+ title={dl.busy ? "Preparing the file..." : "Download"} aria-label={dl.busy ? "Preparing the file" : "Download"}
332
+ onClick={() => menu.setOpen((o) => !o)}>
333
+ {dl.busy ? <span className="nbv-send-spinner" aria-hidden="true" /> : <DownloadIcon size={20} />}
334
+ </button>
335
+ {menu.open && (
336
+ <FloatingMenu menu={menu}>
337
+ <button type="button" role="menuitem" onClick={() => { menu.setOpen(false); void dl.save("pdf"); }}>
338
+ <PdfIcon size={20} /> Download PDF Document (.pdf)
339
+ </button>
340
+ <button type="button" role="menuitem" onClick={() => { menu.setOpen(false); void dl.save("pptx"); }}>
341
+ <PptxIcon size={20} /> Download PowerPoint (.pptx)
342
+ </button>
343
+ </FloatingMenu>
344
+ )}
345
+ {dl.error && <span className="sd-menu-error error-text small" role="alert">{dl.error}</span>}
346
+ </div>
347
+ );
348
+ }
349
+
350
+ /** Everything a deck can do, behind its three dots (the row's and the popup's): rename, download, present, speaker
351
+ * notes (in the popup) and delete (asks once more first). */
352
+ function DeckMenu({ artifact, onRename, onPresent, onDelete, notes, onNotes }: {
353
+ artifact: Artifact;
354
+ onRename?: () => void;
355
+ onPresent: () => void;
356
+ onDelete: () => Promise<void>;
357
+ notes?: boolean;
358
+ onNotes?: () => void;
359
+ }) {
360
+ const menu = useMenu();
361
+ const dl = useDeckDownload(artifact);
362
+ const [confirming, setConfirming] = useState(false);
363
+ const pick = (fn: () => void) => () => {
364
+ menu.setOpen(false);
365
+ fn();
366
+ };
367
+ return (
368
+ <div className="sd-menu-wrap" ref={menu.ref} onClick={(e) => e.stopPropagation()}>
369
+ <button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} title="More" aria-label="More"
370
+ onClick={() => menu.setOpen((o) => !o)}>
371
+ {dl.busy ? <span className="nbv-send-spinner" aria-hidden="true" /> : <MoreIcon size={20} />}
372
+ </button>
373
+ {menu.open && (
374
+ <FloatingMenu menu={menu}>
375
+ {onRename && <button type="button" role="menuitem" onClick={pick(onRename)}><PencilIcon /> Rename</button>}
376
+ <button type="button" role="menuitem" onClick={pick(() => void dl.save("pdf"))}><PdfIcon size={20} /> Download PDF Document (.pdf)</button>
377
+ <button type="button" role="menuitem" onClick={pick(() => void dl.save("pptx"))}><PptxIcon size={20} /> Download PowerPoint (.pptx)</button>
378
+ <button type="button" role="menuitem" onClick={pick(onPresent)}><PlayIcon size={20} /> Start slideshow</button>
379
+ {onNotes && (
380
+ <button type="button" role="menuitemcheckbox" aria-checked={notes} onClick={pick(onNotes)}>
381
+ <SpeakerNotesIcon /> {notes ? "Hide speaker notes" : "Show speaker notes"}
382
+ </button>
383
+ )}
384
+ <button type="button" role="menuitem" className="danger" onClick={pick(() => setConfirming(true))}>
385
+ <TrashIcon size={20} /> Delete
386
+ </button>
387
+ </FloatingMenu>
388
+ )}
389
+ {confirming && <ConfirmDeleteModal heading="Delete slide deck?" name={deckTitle(artifact)} onCancel={() => setConfirming(false)}
390
+ onConfirm={onDelete} />}
391
+ {dl.error && <span className="sd-menu-error error-text small" role="alert">{dl.error}</span>}
392
+ </div>
393
+ );
394
+ }
395
+
396
+ function SvgIcon({ children }: { children: ReactNode }) {
397
+ return (
398
+ <svg className="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}
399
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
400
+ {children}
401
+ </svg>
402
+ );
403
+ }
404
+
405
+ const SpeakerNotesIcon = () => (
406
+ <SvgIcon>
407
+ <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" />
408
+ <path d="M7.5 9h9M7.5 12.5h6" />
409
+ </SvgIcon>
410
+ );
411
+ const PencilIcon = () => (
412
+ <SvgIcon>
413
+ <path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4zM13.5 6.5l4 4" />
414
+ </SvgIcon>
415
+ );
416
+
417
+ /** The deck in a popup: its actions along the top (present, a larger preview, download, more) and every slide below,
418
+ * one after another. A click on a slide presents from it. */
419
+ export function SlideDeckViewer({ artifact, onClose, onDelete }: {
420
+ artifact: Artifact;
421
+ onClose: () => void;
422
+ onDelete: () => Promise<void>;
423
+ }) {
424
+ const { pages, slides } = deckOf(artifact);
425
+ const [big, setBig] = useState(false);
426
+ const [notes, setNotes] = useState(false);
427
+ const [presenting, setPresenting] = useState<number | null>(null);
428
+ const [inView, setInView] = useState(0);
429
+ const stack = useRef<HTMLOListElement>(null);
430
+ const c = artifact.content;
431
+ const theme = SLIDE_THEMES.find((t) => t.id === c.theme)?.name ?? "Dark space";
432
+
433
+ // The slide most in view: Present starts there.
434
+ useEffect(() => {
435
+ const root = stack.current;
436
+ if (!root || typeof IntersectionObserver === "undefined") return;
437
+ const seen = new Map<number, number>();
438
+ const io = new IntersectionObserver((entries) => {
439
+ for (const e of entries) seen.set(Number((e.target as HTMLElement).dataset.i), e.intersectionRatio);
440
+ let best = 0;
441
+ let ratio = -1;
442
+ seen.forEach((r, i) => {
443
+ if (r > ratio) [best, ratio] = [i, r];
444
+ });
445
+ setInView(best);
446
+ }, { root, threshold: [0, 0.25, 0.5, 0.75, 1] });
447
+ root.querySelectorAll("[data-i]").forEach((el) => io.observe(el));
448
+ return () => io.disconnect();
449
+ }, [pages.length, notes]);
450
+
451
+ const actions = (
452
+ <div className="sd-head-actions" role="toolbar" aria-label="Slide deck">
453
+ <button type="button" className="sd-icon-btn" title="Start slideshow" aria-label="Start slideshow" disabled={!pages.length}
454
+ onClick={() => setPresenting(inView)}>
455
+ <PlayIcon size={20} />
456
+ </button>
457
+ <button type="button" className="sd-icon-btn" title={big ? "Smaller preview" : "Larger preview"} aria-pressed={big}
458
+ aria-label={big ? "Smaller preview" : "Larger preview"} onClick={() => setBig((b) => !b)}>
459
+ {big ? <CollapseIcon size={20} /> : <ExpandIcon size={20} />}
460
+ </button>
461
+ {pages.length > 0 && <DownloadMenu artifact={artifact} />}
462
+ <DeckMenu artifact={artifact} onPresent={() => setPresenting(inView)} onDelete={onDelete}
463
+ notes={notes} onNotes={() => setNotes((n) => !n)} />
464
+ <button type="button" className="sd-icon-btn" onClick={onClose} title="Close" aria-label="Close">
465
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
466
+ <path d="M6 6l12 12M18 6L6 18" />
467
+ </svg>
468
+ </button>
469
+ </div>
470
+ );
471
+
472
+ return (
473
+ <Modal title={deckTitle(artifact)} onClose={onClose} wide actions={actions}>
474
+ <div className={`sd-view${big ? " big" : ""}`}>
475
+ <p className="muted small sd-meta">
476
+ {pages.length} slides · {c.format === "presenter" ? "Presenter" : "Detailed"} · {theme}
477
+ {c.source ? ` · from ${sourceSummary(c.source)}` : ""}
478
+ </p>
479
+ {pages.length === 0 ? (
480
+ <p className="error-text">This deck has no slides to show. Try generating it again.</p>
481
+ ) : (
482
+ <ol className="sd-stack" ref={stack} aria-label="Slides">
483
+ {pages.map((html, k) => {
484
+ const s = slides[k];
485
+ const sources = [...new Map((s?.sources ?? []).map((x) => [x.title || x.path, x])).values()];
486
+ return (
487
+ <li key={k} data-i={k}>
488
+ <button type="button" className="sd-slide" onClick={() => setPresenting(k)}
489
+ aria-label={`Present from slide ${k + 1}: ${s?.heading ?? ""}`}>
490
+ <SlideFrame html={html} title={`Slide ${k + 1}`} />
491
+ </button>
492
+ {notes && (
493
+ <div className="sd-notes">
494
+ <span className="vw-label">Slide {k + 1}</span>
495
+ <p>{s?.notes || <span className="muted">No notes for this slide.</span>}</p>
496
+ {sources.length > 0 && (
497
+ <p className="muted small">
498
+ From {sources.map((x) => `${x.title || x.path} (lines ${x.line_start} to ${x.line_end})`).join("; ")}
499
+ </p>
500
+ )}
501
+ </div>
502
+ )}
503
+ </li>
504
+ );
505
+ })}
506
+ </ol>
507
+ )}
508
+ </div>
509
+ {presenting !== null && <SlidePresenter artifact={artifact} start={presenting} onClose={() => setPresenting(null)} />}
510
+ </Modal>
511
+ );
512
+ }
513
+
514
+ /** A deck in the notebook's list, as one quiet row: its icon, its title, "2 sources · 5d ago", and its menu. A click
515
+ * opens it. While it is being made the row says so and shows the progress. */
516
+ export function SlideDeckRow({ artifact: initial, onRemoved }: { artifact: Artifact; onRemoved: (id: string) => void }) {
517
+ const [artifact, setArtifact] = useState(initial);
518
+ const [open, setOpen] = useState(false);
519
+ const [presenting, setPresenting] = useState(false);
520
+ const [renaming, setRenaming] = useState(false);
521
+ const [name, setName] = useState("");
522
+ const [error, setError] = useState<string | null>(null);
523
+ const job = useJob(artifact.job, () => {
524
+ artifactsApi.get(artifact.id).then(setArtifact);
525
+ });
526
+ const running = job && (job.status === "queued" || job.status === "running");
527
+ const ready = artifact.status === "ready";
528
+ const failed = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error ?? "Could not make this deck." : null;
529
+ const title = deckTitle(artifact);
530
+ const count = (artifact.content.source?.count as number | undefined) ?? 0;
531
+ const meta = running || !ready
532
+ ? (failed ? "Slide deck · could not be made" : `Generating slide deck... based on ${count || "your"} source${count === 1 ? "" : "s"}`)
533
+ : `${count} source${count === 1 ? "" : "s"} · ${ago(artifact.created_at)}`;
534
+
535
+ const remove = async () => {
536
+ await artifactsApi.remove(artifact.id);
537
+ setOpen(false);
538
+ onRemoved(artifact.id);
539
+ };
540
+ const rename = async () => {
541
+ const next = name.trim();
542
+ setRenaming(false);
543
+ if (!next || next === title) return;
544
+ setError(null);
545
+ try {
546
+ const deck = artifact.content.deck ? { ...artifact.content.deck, title: next } : undefined;
547
+ setArtifact(await artifactsApi.update(artifact.id, { title: next, ...(deck ? { content: { deck } } : {}) }));
548
+ } catch (e) {
549
+ setError(errorMessage(e));
550
+ }
551
+ };
552
+
553
+ return (
554
+ <div className={`sd-item${ready ? " is-ready" : ""}${running ? " is-running" : ""}`}>
555
+ <div className="sd-item-main" role={ready && !renaming ? "button" : undefined} tabIndex={ready && !renaming ? 0 : undefined}
556
+ aria-label={ready && !renaming ? `Open slide deck: ${title}` : undefined}
557
+ onClick={() => ready && !renaming && setOpen(true)}
558
+ onKeyDown={(e) => ready && !renaming && (e.key === "Enter" || e.key === " ") && (e.preventDefault(), setOpen(true))}>
559
+ <span className="sd-item-icon" aria-hidden="true">
560
+ {running ? <span className="nbv-send-spinner" /> : <SlidesIcon size={22} />}
561
+ </span>
562
+ <span className="sd-item-text">
563
+ {renaming ? (
564
+ <input className="input sd-rename" autoFocus value={name} maxLength={140} aria-label="Slide deck name"
565
+ onChange={(e) => setName(e.target.value)} onBlur={rename} onClick={(e) => e.stopPropagation()}
566
+ onKeyDown={(e) => {
567
+ e.stopPropagation();
568
+ if (e.key === "Enter") void rename();
569
+ if (e.key === "Escape") setRenaming(false);
570
+ }} />
571
+ ) : (
572
+ <strong title={title}>{running ? "Generating slide deck..." : title}</strong>
573
+ )}
574
+ <span className="sd-item-meta">{meta}</span>
575
+ </span>
576
+ </div>
577
+ {ready && (
578
+ <DeckMenu artifact={artifact} onRename={() => { setName(title); setRenaming(true); }} onPresent={() => setPresenting(true)}
579
+ onDelete={remove} />
580
+ )}
581
+ {running && job && (
582
+ <div className="sd-item-progress">
583
+ <JobProgress job={job} compact />
584
+ </div>
585
+ )}
586
+ {failed && (
587
+ <div className="sd-item-progress sd-item-failed">
588
+ <p className="error-text small">{failed}</p>
589
+ <button type="button" className="link-btn" onClick={async () => {
590
+ setError(null);
591
+ try {
592
+ setArtifact(await artifactsApi.retry(artifact.id));
593
+ } catch (e) {
594
+ setError(errorMessage(e));
595
+ }
596
+ }}>Retry</button>
597
+ <button type="button" className="link-btn" onClick={() => void remove()}>Remove</button>
598
+ </div>
599
+ )}
600
+ {error && <p className="error-text small sd-item-progress">{error}</p>}
601
+ {open && <SlideDeckViewer artifact={artifact} onClose={() => setOpen(false)} onDelete={remove} />}
602
+ {presenting && <SlidePresenter artifact={artifact} onClose={() => setPresenting(false)} />}
603
+ </div>
604
+ );
605
+ }
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/ui.tsx CHANGED
@@ -171,6 +171,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,
 
171
  }
172
 
173
  /** Two step destructive button: first click arms it, second confirms. No browser dialogs. */
174
+ /** "Delete this?": deletes only on confirm, shows what went wrong if it fails, and closes once it is gone. */
175
+ export function ConfirmDeleteModal({ heading, name, onCancel, onConfirm }: {
176
+ heading: string;
177
+ name: string;
178
+ onCancel: () => void;
179
+ onConfirm: () => Promise<void>;
180
+ }) {
181
+ const [busy, setBusy] = useState(false);
182
+ const [error, setError] = useState<string | null>(null);
183
+ const confirm = async () => {
184
+ setBusy(true);
185
+ setError(null);
186
+ try {
187
+ await onConfirm();
188
+ onCancel(); // gone: close the warning
189
+ } catch (e) {
190
+ setError(errorMessage(e));
191
+ setBusy(false);
192
+ }
193
+ };
194
+ return (
195
+ <Modal title={heading} onClose={busy ? () => undefined : onCancel}>
196
+ <div className="stack sd-confirm">
197
+ <p>
198
+ <strong>{name}</strong> will be deleted for good. This cannot be undone.
199
+ </p>
200
+ {error && <p className="error-text" role="alert">{error}</p>}
201
+ <div className="row sd-confirm-actions">
202
+ <button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
203
+ <button type="button" className="btn btn-danger" onClick={confirm} disabled={busy} autoFocus>
204
+ {busy ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Deleting...</>) : "Delete"}
205
+ </button>
206
+ </div>
207
+ </div>
208
+ </Modal>
209
+ );
210
+ }
211
+
212
  export function ConfirmButton({
213
  onConfirm,
214
  children,
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/styles/pages.css CHANGED
@@ -3415,7 +3415,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 +3484,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
 
@@ -4612,52 +4613,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 +7514,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 +8175,954 @@ details.map-legend[open] summary {
8221
  .nbv-starting-text strong {
8222
  font: 500 0.92rem var(--font-body);
8223
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3415
  }
3416
 
3417
  .nbv {
3418
+ position: relative; /* the resize handles are placed over the boundaries */
3419
+ grid-template-columns: var(--nbv-left, clamp(16rem, 20vw, 20rem)) minmax(0, 1fr);
3420
  grid-template-areas:
3421
  "history chat"
3422
  "history side";
 
3484
  }
3485
 
3486
  .nbv {
3487
+ grid-template-columns: var(--nbv-left, clamp(16rem, 20vw, 20rem)) minmax(0, 1fr) var(--nbv-right, clamp(18rem, 24vw, 24rem));
3488
  grid-template-areas: "history chat side";
3489
  }
3490
 
3491
  .nbv.nbv-history-closed {
3492
+ grid-template-columns: minmax(0, 1fr) var(--nbv-right, clamp(18rem, 24vw, 24rem));
3493
  grid-template-areas: "chat side";
3494
  }
3495
 
 
4613
  border-color: var(--blue);
4614
  }
4615
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4616
  /* ---------- Mind map: the right-rail tile and list, the dialog, and the galaxy explorer ---------- */
4617
  .nbv-make .nbv-make-map {
4618
  grid-column: 1 / -1;
 
7514
  border-top-color: var(--black);
7515
  }
7516
 
7517
+ /* Create buttons: two to a row. Audio overview shares its row with Slide deck; its options and Mind Constellation take
7518
+ a whole row. */
7519
  .nbv-make {
7520
  grid-template-columns: repeat(2, minmax(0, 1fr));
7521
  }
7522
 
 
 
7523
  .nbv-make .nbv-make-stars {
7524
  grid-column: 1 / -1;
7525
  }
 
8175
  .nbv-starting-text strong {
8176
  font: 500 0.92rem var(--font-body);
8177
  }
8178
+
8179
+
8180
+ /* ---- Slide decks ------------------------------------------------------------------------------------------------ */
8181
+
8182
+ .sd-dialog {
8183
+ gap: 22px;
8184
+ }
8185
+
8186
+ /* The settings: the two styles across one row, then format, length and language side by side */
8187
+ .sd-themes {
8188
+ display: grid;
8189
+ grid-template-columns: repeat(2, minmax(0, 220px));
8190
+ justify-content: center;
8191
+ gap: 16px;
8192
+ }
8193
+
8194
+ .sd-theme {
8195
+ display: grid;
8196
+ gap: 6px;
8197
+ padding: 5px 5px 8px;
8198
+ text-align: left;
8199
+ border: 1px solid var(--line);
8200
+ border-radius: 12px;
8201
+ background: transparent;
8202
+ color: inherit;
8203
+ cursor: pointer;
8204
+ transition: border-color 0.15s, box-shadow 0.15s;
8205
+ }
8206
+
8207
+ .sd-theme:hover {
8208
+ border-color: var(--line-strong);
8209
+ }
8210
+
8211
+ .sd-theme.on {
8212
+ border-color: var(--blue);
8213
+ box-shadow: 0 0 0 1px var(--blue), var(--glow-sm);
8214
+ }
8215
+
8216
+ .sd-theme-img {
8217
+ position: relative;
8218
+ display: block;
8219
+ }
8220
+
8221
+ .sd-theme-img img {
8222
+ display: block;
8223
+ width: 100%;
8224
+ aspect-ratio: 16 / 9; /* the whole slide, small */
8225
+ object-fit: cover;
8226
+ border-radius: 8px;
8227
+ }
8228
+
8229
+ .sd-check {
8230
+ position: absolute;
8231
+ top: 6px;
8232
+ right: 6px;
8233
+ display: grid;
8234
+ place-items: center;
8235
+ width: 22px;
8236
+ height: 22px;
8237
+ border-radius: 50%;
8238
+ background: var(--black);
8239
+ color: var(--white);
8240
+ box-shadow: 0 0 0 2px var(--blue);
8241
+ }
8242
+
8243
+ .sd-theme-name {
8244
+ padding: 0 4px;
8245
+ text-align: center;
8246
+ font-size: 0.85rem;
8247
+ font-weight: 600;
8248
+ }
8249
+
8250
+ .sd-opts {
8251
+ display: grid;
8252
+ grid-template-columns: auto minmax(210px, 1fr) minmax(140px, 0.7fr); /* format as wide as its pills */
8253
+ column-gap: 28px;
8254
+ row-gap: 14px;
8255
+ align-items: start;
8256
+ }
8257
+
8258
+ .sd-opts .field {
8259
+ gap: 8px;
8260
+ min-width: 0;
8261
+ }
8262
+
8263
+ /* The dropdowns stay inside their column: a long value shortens with an ellipsis */
8264
+ .sd-opts .dropdown,
8265
+ .sd-opts .dropdown-btn {
8266
+ min-width: 0;
8267
+ max-width: 100%;
8268
+ }
8269
+
8270
+ .sd-opts .dropdown-value {
8271
+ overflow: hidden;
8272
+ text-overflow: ellipsis;
8273
+ white-space: nowrap;
8274
+ }
8275
+
8276
+ /* The length's slide range, inside its dropdown: the same small grey text as the hints under the other fields */
8277
+ .sd-opts .dropdown-value .muted {
8278
+ font-size: 0.83em;
8279
+ }
8280
+
8281
+ .sd-opts .dropdown-btn {
8282
+ padding-left: 16px;
8283
+ padding-right: 14px;
8284
+ }
8285
+
8286
+ /* Format pills look exactly like the dropdowns beside them: same height, text, border and fill */
8287
+ .sd-opts .vw-chips {
8288
+ flex-wrap: nowrap;
8289
+ gap: 8px;
8290
+ }
8291
+
8292
+ .sd-opts .vw-chip {
8293
+ display: inline-flex;
8294
+ align-items: center;
8295
+ height: 48px;
8296
+ padding: 0 18px;
8297
+ border: 1px solid var(--line-strong);
8298
+ border-radius: 999px;
8299
+ background: rgba(0, 0, 0, 0.6);
8300
+ color: var(--text);
8301
+ font: 500 1rem var(--font-body);
8302
+ white-space: nowrap;
8303
+ }
8304
+
8305
+ .sd-opts .vw-chip.on {
8306
+ border-color: var(--blue);
8307
+ background: var(--blue);
8308
+ color: var(--white);
8309
+ }
8310
+
8311
+ .sd-opts .vw-chip:hover:not(.on) {
8312
+ border-color: var(--blue-60);
8313
+ }
8314
+
8315
+ /* The format's description wraps under the pills instead of widening their column */
8316
+ .sd-opts > .field:first-child > small {
8317
+ display: block;
8318
+ width: 0;
8319
+ min-width: 100%;
8320
+ }
8321
+
8322
+ /* A little more room than other dialogs, for the three settings side by side */
8323
+ .modal.modal-wide:has(.sd-dialog) {
8324
+ width: min(840px, calc(100vw - 32px));
8325
+ }
8326
+
8327
+ .sd-prompt {
8328
+ width: 100%;
8329
+ min-height: 92px;
8330
+ padding: 14px 18px;
8331
+ border-radius: 14px;
8332
+ line-height: 1.55;
8333
+ resize: vertical;
8334
+ }
8335
+
8336
+ /* One 1920 x 1080 slide, scaled into whatever box it is given. */
8337
+ .sd-frame {
8338
+ position: relative;
8339
+ width: 100%;
8340
+ overflow: hidden;
8341
+ background: #000;
8342
+ }
8343
+
8344
+ .sd-frame-width {
8345
+ aspect-ratio: 16 / 9;
8346
+ }
8347
+
8348
+ .sd-frame-contain {
8349
+ position: absolute;
8350
+ inset: 0;
8351
+ background: transparent;
8352
+ }
8353
+
8354
+ .sd-frame iframe {
8355
+ position: absolute;
8356
+ top: 50%;
8357
+ left: 50%;
8358
+ border: 0;
8359
+ transform-origin: center center;
8360
+ pointer-events: none;
8361
+ }
8362
+
8363
+ /* A deck in the notebook's list: a quiet row (icon, title, "2 sources · 5d ago", menu), highlighted on hover */
8364
+ .sd-item {
8365
+ display: grid;
8366
+ grid-template-columns: minmax(0, 1fr) auto;
8367
+ align-items: center;
8368
+ column-gap: 4px;
8369
+ padding: 4px 4px 4px 0;
8370
+ border-radius: 16px;
8371
+ transition: background 0.15s;
8372
+ }
8373
+
8374
+ .sd-item.is-ready:hover,
8375
+ .sd-item:focus-within {
8376
+ background: var(--surface-2);
8377
+ }
8378
+
8379
+ .sd-item-main {
8380
+ display: grid;
8381
+ grid-template-columns: 40px minmax(0, 1fr);
8382
+ align-items: center;
8383
+ gap: 10px;
8384
+ min-width: 0;
8385
+ padding: 8px 4px 8px 10px;
8386
+ border-radius: 16px;
8387
+ outline: none;
8388
+ }
8389
+
8390
+ .sd-item.is-ready .sd-item-main {
8391
+ cursor: pointer;
8392
+ }
8393
+
8394
+ .sd-item-main:focus-visible {
8395
+ box-shadow: 0 0 0 2px var(--blue);
8396
+ }
8397
+
8398
+ .sd-item-icon {
8399
+ display: grid;
8400
+ place-items: center;
8401
+ width: 40px;
8402
+ height: 40px;
8403
+ color: var(--blue);
8404
+ }
8405
+
8406
+ .sd-item-text {
8407
+ display: grid;
8408
+ gap: 2px;
8409
+ min-width: 0;
8410
+ }
8411
+
8412
+ .sd-item-text strong {
8413
+ overflow: hidden;
8414
+ text-overflow: ellipsis;
8415
+ white-space: nowrap;
8416
+ font-weight: 500;
8417
+ font-size: 0.95rem;
8418
+ color: var(--text);
8419
+ }
8420
+
8421
+ .sd-item-meta {
8422
+ overflow: hidden;
8423
+ text-overflow: ellipsis;
8424
+ white-space: nowrap;
8425
+ font-size: 0.82rem;
8426
+ color: var(--text-3);
8427
+ }
8428
+
8429
+ .sd-item .sd-menu-wrap .sd-icon-btn {
8430
+ color: var(--text-2);
8431
+ }
8432
+
8433
+ .sd-item-progress {
8434
+ grid-column: 1 / -1;
8435
+ padding: 0 12px 8px 60px;
8436
+ }
8437
+
8438
+ .sd-item-failed {
8439
+ display: flex;
8440
+ align-items: center;
8441
+ gap: 12px;
8442
+ flex-wrap: wrap;
8443
+ }
8444
+
8445
+ .sd-item-failed p {
8446
+ flex: 1;
8447
+ margin: 0;
8448
+ }
8449
+
8450
+ .sd-rename {
8451
+ width: 100%;
8452
+ padding: 4px 10px;
8453
+ font-size: 0.95rem;
8454
+ }
8455
+
8456
+ /* The deck's popup: actions along the top, every slide below it */
8457
+ .modal.modal-wide:has(.sd-view) {
8458
+ width: min(980px, calc(100vw - 32px));
8459
+ max-height: calc(100vh - 32px);
8460
+ display: flex;
8461
+ flex-direction: column;
8462
+ transition: width 0.2s ease;
8463
+ }
8464
+
8465
+ .modal.modal-wide:has(.sd-view.big) {
8466
+ width: calc(100vw - 32px);
8467
+ height: calc(100vh - 32px);
8468
+ }
8469
+
8470
+ .modal:has(.sd-view) .modal-head {
8471
+ gap: 12px;
8472
+ }
8473
+
8474
+ .modal:has(.sd-view) .modal-head h2 {
8475
+ min-width: 0;
8476
+ overflow: hidden;
8477
+ text-overflow: ellipsis;
8478
+ white-space: nowrap;
8479
+ }
8480
+
8481
+ .sd-head-actions {
8482
+ display: flex;
8483
+ align-items: center;
8484
+ gap: 4px;
8485
+ flex: none;
8486
+ }
8487
+
8488
+ .sd-icon-btn {
8489
+ display: grid;
8490
+ place-items: center;
8491
+ width: 38px;
8492
+ height: 38px;
8493
+ padding: 0;
8494
+ border: 0;
8495
+ border-radius: 50%;
8496
+ background: transparent;
8497
+ color: var(--text);
8498
+ cursor: pointer;
8499
+ transition: background 0.15s, color 0.15s;
8500
+ }
8501
+
8502
+ .sd-icon-btn:hover:not(:disabled),
8503
+ .sd-icon-btn[aria-expanded="true"],
8504
+ .sd-icon-btn[aria-pressed="true"] {
8505
+ background: var(--surface-2);
8506
+ color: var(--white);
8507
+ }
8508
+
8509
+ .sd-icon-btn:focus-visible {
8510
+ outline: 2px solid var(--blue);
8511
+ outline-offset: 1px;
8512
+ }
8513
+
8514
+ .sd-icon-btn:disabled {
8515
+ opacity: 0.5;
8516
+ cursor: default;
8517
+ }
8518
+
8519
+ .sd-menu-wrap {
8520
+ position: relative;
8521
+ }
8522
+
8523
+ /* In <body>, above popups (z-index over the modal's backdrop) */
8524
+ .sd-menu.sd-menu-floating {
8525
+ position: fixed;
8526
+ top: auto;
8527
+ right: auto;
8528
+ z-index: 2100;
8529
+ }
8530
+
8531
+ .sd-menu {
8532
+ position: absolute;
8533
+ top: calc(100% + 6px);
8534
+ right: 0;
8535
+ z-index: 20;
8536
+ display: grid;
8537
+ min-width: 270px;
8538
+ padding: 6px;
8539
+ border: 1px solid var(--line-strong);
8540
+ border-radius: var(--radius-sm);
8541
+ background: var(--black);
8542
+ box-shadow: var(--glow-lg);
8543
+ }
8544
+
8545
+ .sd-menu button {
8546
+ display: flex;
8547
+ align-items: center;
8548
+ gap: 12px;
8549
+ padding: 10px 12px;
8550
+ border: 0;
8551
+ border-radius: 6px;
8552
+ background: transparent;
8553
+ color: var(--white);
8554
+ font: 500 0.92rem var(--font-body);
8555
+ text-align: left;
8556
+ white-space: nowrap;
8557
+ cursor: pointer;
8558
+ }
8559
+
8560
+ .sd-menu button:hover,
8561
+ .sd-menu button:focus-visible {
8562
+ background: var(--surface-2);
8563
+ }
8564
+
8565
+ .sd-menu button.danger {
8566
+ color: #ff8a8a;
8567
+ }
8568
+
8569
+ .sd-confirm p {
8570
+ margin: 0;
8571
+ line-height: 1.6;
8572
+ }
8573
+
8574
+ .sd-confirm-actions {
8575
+ justify-content: flex-end;
8576
+ gap: 10px;
8577
+ }
8578
+
8579
+ .sd-menu .error-text {
8580
+ margin: 4px 8px;
8581
+ }
8582
+
8583
+ .sd-menu-error {
8584
+ position: absolute;
8585
+ top: calc(100% + 6px);
8586
+ right: 0;
8587
+ width: 240px;
8588
+ text-align: right;
8589
+ }
8590
+
8591
+ .sd-view {
8592
+ display: grid;
8593
+ gap: 12px;
8594
+ min-height: 0;
8595
+ flex: 1;
8596
+ }
8597
+
8598
+ .sd-meta {
8599
+ margin: 0;
8600
+ }
8601
+
8602
+ .sd-stack {
8603
+ display: grid;
8604
+ gap: 16px;
8605
+ max-height: calc(100vh - 190px);
8606
+ margin: 0;
8607
+ padding: 0 6px 6px 0;
8608
+ overflow-y: auto;
8609
+ list-style: none;
8610
+ }
8611
+
8612
+ .sd-view.big .sd-stack {
8613
+ max-height: calc(100vh - 160px);
8614
+ justify-items: center;
8615
+ }
8616
+
8617
+ .sd-view.big .sd-stack > li {
8618
+ width: min(100%, calc((100vh - 190px) * 16 / 9));
8619
+ }
8620
+
8621
+ .sd-slide {
8622
+ display: block;
8623
+ width: 100%;
8624
+ padding: 0;
8625
+ border: 1px solid var(--line);
8626
+ border-radius: 14px;
8627
+ overflow: hidden;
8628
+ background: none;
8629
+ cursor: pointer;
8630
+ transition: border-color 0.15s, box-shadow 0.15s;
8631
+ }
8632
+
8633
+ .sd-slide:hover,
8634
+ .sd-slide:focus-visible {
8635
+ border-color: var(--blue-60);
8636
+ box-shadow: var(--glow-sm);
8637
+ }
8638
+
8639
+ .sd-notes {
8640
+ display: grid;
8641
+ gap: 6px;
8642
+ margin-top: 8px;
8643
+ padding: 14px 18px;
8644
+ border: 1px solid var(--line);
8645
+ border-radius: var(--radius-sm);
8646
+ background: var(--surface);
8647
+ line-height: 1.6;
8648
+ }
8649
+
8650
+ .sd-notes p {
8651
+ margin: 0;
8652
+ }
8653
+
8654
+ /* Presenting: the slide fills the screen, letterboxed in its own sky colour; the controls fade when the mouse rests. */
8655
+ .sd-present {
8656
+ position: fixed;
8657
+ inset: 0;
8658
+ z-index: 1000;
8659
+ background: #000;
8660
+ cursor: pointer;
8661
+ }
8662
+
8663
+ .sd-present.light {
8664
+ background: #e9eef9;
8665
+ }
8666
+
8667
+ .sd-present-stage {
8668
+ position: absolute;
8669
+ inset: 0;
8670
+ }
8671
+
8672
+ .sd-present-bar {
8673
+ position: absolute;
8674
+ left: 50%;
8675
+ bottom: 22px;
8676
+ display: flex;
8677
+ align-items: center;
8678
+ gap: 10px;
8679
+ padding: 8px 12px;
8680
+ transform: translateX(-50%);
8681
+ border: 1px solid var(--line-strong);
8682
+ border-radius: 999px;
8683
+ background: rgba(0, 0, 0, 0.72);
8684
+ color: var(--white);
8685
+ cursor: default;
8686
+ transition: opacity 0.3s;
8687
+ }
8688
+
8689
+ .sd-present.idle {
8690
+ cursor: none;
8691
+ }
8692
+
8693
+ .sd-present.idle .sd-present-bar {
8694
+ opacity: 0;
8695
+ }
8696
+
8697
+ .sd-count {
8698
+ min-width: 64px;
8699
+ text-align: center;
8700
+ font: 500 0.85rem var(--font-mono, monospace);
8701
+ }
8702
+
8703
+ @media (max-width: 760px) {
8704
+ .sd-opts {
8705
+ grid-template-columns: 1fr;
8706
+ }
8707
+
8708
+ .sd-opts .vw-chips {
8709
+ flex-wrap: wrap;
8710
+ }
8711
+
8712
+ .modal:has(.sd-view) .modal-head {
8713
+ flex-wrap: wrap;
8714
+ }
8715
+
8716
+ .sd-icon-btn {
8717
+ width: 34px;
8718
+ height: 34px;
8719
+ }
8720
+ }
8721
+
8722
+ @media (prefers-reduced-motion: reduce) {
8723
+ .sd-present-bar,
8724
+ .sd-item,
8725
+ .sd-slide,
8726
+ .sd-icon-btn,
8727
+ .modal.modal-wide:has(.sd-view),
8728
+ .sd-theme {
8729
+ transition: none;
8730
+ }
8731
+ }
8732
+
8733
+
8734
+ /* ---- Notebook: side panels resized by dragging the line between a panel and the chat (components/PaneResizer) ---- */
8735
+
8736
+ .nbv-resizer {
8737
+ display: none;
8738
+ }
8739
+
8740
+ @media (min-width: 900px) {
8741
+ .nbv-resizer {
8742
+ position: absolute;
8743
+ top: 0;
8744
+ bottom: 0;
8745
+ z-index: 6;
8746
+ display: block;
8747
+ width: 12px;
8748
+ margin-left: -6px;
8749
+ cursor: col-resize;
8750
+ touch-action: none;
8751
+ }
8752
+
8753
+ .nbv-resizer::after {
8754
+ content: "";
8755
+ position: absolute;
8756
+ top: 14px;
8757
+ bottom: 14px;
8758
+ left: 5px;
8759
+ width: 2px;
8760
+ border-radius: 2px;
8761
+ background: transparent;
8762
+ transition: background 0.15s, box-shadow 0.15s;
8763
+ }
8764
+
8765
+ .nbv-resizer:hover::after,
8766
+ .nbv-resizer.dragging::after,
8767
+ .nbv-resizer:focus-visible::after {
8768
+ background: var(--blue);
8769
+ box-shadow: 0 0 10px var(--blue-60);
8770
+ }
8771
+
8772
+ .nbv-resizer:focus-visible {
8773
+ outline: none;
8774
+ }
8775
+ }
8776
+
8777
+ /* While dragging: no text selection, the resize cursor everywhere, and frames (slides, infographics) let the pointer
8778
+ through so the drag is never lost over them. */
8779
+ body.nbv-resizing,
8780
+ body.nbv-resizing * {
8781
+ cursor: col-resize !important;
8782
+ user-select: none;
8783
+ }
8784
+
8785
+ body.nbv-resizing iframe {
8786
+ pointer-events: none;
8787
+ }
8788
+
8789
+ /* The panels' contents follow the panels' own widths, not the window's. */
8790
+ .nbv-side,
8791
+ .nbv-history,
8792
+ .nbv-chat {
8793
+ container-type: inline-size;
8794
+ }
8795
+
8796
+ /* Create tiles never wrap a word: the label shortens with an ellipsis, the icon stays. */
8797
+ .nbv-make > button,
8798
+ .nbv-make .nbv-make-label {
8799
+ min-width: 0;
8800
+ overflow: hidden;
8801
+ }
8802
+
8803
+ .nbv-make .nbv-make-label {
8804
+ flex: 1;
8805
+ }
8806
+
8807
+ .nbv-make-text {
8808
+ min-width: 0;
8809
+ overflow: hidden;
8810
+ text-overflow: ellipsis;
8811
+ white-space: nowrap;
8812
+ }
8813
+
8814
+ /* Tiles per row by the panel's width: one when narrow, two by default, three or four when the panel is wide. */
8815
+ @container (max-width: 299px) {
8816
+ .nbv-make {
8817
+ grid-template-columns: minmax(0, 1fr);
8818
+ }
8819
+ }
8820
+
8821
+ @container (min-width: 560px) {
8822
+ .nbv-make {
8823
+ grid-template-columns: repeat(3, minmax(0, 1fr));
8824
+ }
8825
+
8826
+ .nbv-make .nbv-make-wide {
8827
+ grid-column: auto;
8828
+ }
8829
+ }
8830
+
8831
+ @container (min-width: 760px) {
8832
+ .nbv-make {
8833
+ grid-template-columns: repeat(4, minmax(0, 1fr));
8834
+ }
8835
+ }
8836
+
8837
+ @media (prefers-reduced-motion: reduce) {
8838
+ .nbv-resizer::after {
8839
+ transition: none;
8840
+ }
8841
+ }
8842
+
8843
+
8844
+ /* ---- Audio overview settings (components/AudioOverview) ---- */
8845
+
8846
+ .ao-dialog {
8847
+ gap: 20px;
8848
+ }
8849
+
8850
+ .ao-styles {
8851
+ display: grid;
8852
+ grid-template-columns: repeat(4, minmax(0, 1fr));
8853
+ gap: 10px;
8854
+ }
8855
+
8856
+ .ao-style {
8857
+ display: grid;
8858
+ align-content: start;
8859
+ gap: 8px;
8860
+ min-height: 132px;
8861
+ padding: 14px 14px 16px;
8862
+ text-align: left;
8863
+ border: 1px solid var(--line-strong);
8864
+ border-radius: 18px;
8865
+ background: rgba(0, 0, 0, 0.6);
8866
+ color: var(--text);
8867
+ cursor: pointer;
8868
+ transition: border-color 0.15s, background 0.15s;
8869
+ }
8870
+
8871
+ .ao-style:hover:not(:disabled):not(.on) {
8872
+ border-color: var(--blue-60);
8873
+ }
8874
+
8875
+ .ao-style.on {
8876
+ border-color: var(--blue);
8877
+ background: var(--blue-20);
8878
+ }
8879
+
8880
+ .ao-style:disabled {
8881
+ opacity: 0.45;
8882
+ cursor: not-allowed;
8883
+ }
8884
+
8885
+ .ao-style-head {
8886
+ display: flex;
8887
+ align-items: center;
8888
+ justify-content: space-between;
8889
+ gap: 8px;
8890
+ }
8891
+
8892
+ .ao-style-head strong {
8893
+ font-weight: 600;
8894
+ }
8895
+
8896
+ .ao-radio {
8897
+ display: grid;
8898
+ place-items: center;
8899
+ flex: none;
8900
+ width: 22px;
8901
+ height: 22px;
8902
+ border-radius: 50%;
8903
+ border: 1px solid var(--line-strong);
8904
+ }
8905
+
8906
+ .ao-style.on .ao-radio {
8907
+ border-color: var(--blue);
8908
+ background: var(--black);
8909
+ }
8910
+
8911
+ .ao-style-blurb {
8912
+ font-size: 0.85rem;
8913
+ line-height: 1.45;
8914
+ color: var(--text-2);
8915
+ }
8916
+
8917
+ /* Hosts pills share the slide deck's pill look (.sd-opts); the two voice pickers sit side by side */
8918
+ .ao-voices {
8919
+ display: grid;
8920
+ grid-template-columns: repeat(2, minmax(0, 1fr));
8921
+ gap: 14px;
8922
+ }
8923
+
8924
+ .ao-voices > p {
8925
+ grid-column: 1 / -1;
8926
+ margin: 0;
8927
+ }
8928
+
8929
+ @media (max-width: 760px) {
8930
+ .ao-styles {
8931
+ grid-template-columns: repeat(2, minmax(0, 1fr));
8932
+ }
8933
+
8934
+ .ao-voices {
8935
+ grid-template-columns: 1fr;
8936
+ }
8937
+ }
8938
+
8939
+ @media (prefers-reduced-motion: reduce) {
8940
+ .ao-style {
8941
+ transition: none;
8942
+ }
8943
+ }
8944
+
8945
+ .modal.modal-wide:has(.ao-dialog) {
8946
+ width: min(880px, calc(100vw - 32px));
8947
+ }
8948
+
8949
+ /* ---- Audio overview in the notebook's list (components/AudioOverview: AudioOverviewCard) ---- */
8950
+
8951
+ .ao-card {
8952
+ border: 1px solid var(--line);
8953
+ border-radius: var(--radius);
8954
+ background: var(--surface);
8955
+ transition: border-color 0.15s;
8956
+ }
8957
+
8958
+ .ao-card.open {
8959
+ border-color: var(--line-strong);
8960
+ }
8961
+
8962
+ .ao-card-head {
8963
+ display: grid;
8964
+ grid-template-columns: 40px minmax(0, 1fr) auto;
8965
+ align-items: center;
8966
+ gap: 10px;
8967
+ padding: 10px 10px 10px 8px;
8968
+ border-radius: var(--radius);
8969
+ outline: none;
8970
+ }
8971
+
8972
+ .ao-card-head[role="button"] {
8973
+ cursor: pointer;
8974
+ }
8975
+
8976
+ .ao-card-head[role="button"]:hover {
8977
+ background: var(--surface-2);
8978
+ }
8979
+
8980
+ .ao-card-head:focus-visible {
8981
+ box-shadow: 0 0 0 2px var(--blue);
8982
+ }
8983
+
8984
+ .ao-card-icon {
8985
+ display: grid;
8986
+ place-items: center;
8987
+ width: 40px;
8988
+ height: 40px;
8989
+ color: var(--blue);
8990
+ }
8991
+
8992
+ .ao-card-text {
8993
+ display: grid;
8994
+ gap: 2px;
8995
+ min-width: 0;
8996
+ }
8997
+
8998
+ .ao-card-text strong {
8999
+ overflow: hidden;
9000
+ text-overflow: ellipsis;
9001
+ white-space: nowrap;
9002
+ font-weight: 500;
9003
+ font-size: 0.95rem;
9004
+ }
9005
+
9006
+ .ao-card-meta {
9007
+ overflow: hidden;
9008
+ text-overflow: ellipsis;
9009
+ white-space: nowrap;
9010
+ font-size: 0.82rem;
9011
+ color: var(--text-3);
9012
+ }
9013
+
9014
+ .ao-chevron {
9015
+ display: grid;
9016
+ place-items: center;
9017
+ width: 32px;
9018
+ height: 32px;
9019
+ color: var(--text-2);
9020
+ }
9021
+
9022
+ .ao-card-progress {
9023
+ padding: 0 12px 10px 58px;
9024
+ }
9025
+
9026
+ .ao-card-body {
9027
+ display: grid;
9028
+ gap: 12px;
9029
+ padding: 4px 14px 14px;
9030
+ }
9031
+
9032
+ /* The player: play, time, a seek bar and the speed, in one pill */
9033
+ .ao-player {
9034
+ display: grid;
9035
+ grid-template-columns: auto auto minmax(0, 1fr) auto;
9036
+ align-items: center;
9037
+ gap: 12px;
9038
+ padding: 6px 8px 6px 6px;
9039
+ border-radius: 999px;
9040
+ background: var(--surface-2);
9041
+ }
9042
+
9043
+ .ao-play {
9044
+ display: grid;
9045
+ place-items: center;
9046
+ width: 38px;
9047
+ height: 38px;
9048
+ border: 0;
9049
+ border-radius: 50%;
9050
+ background: var(--blue);
9051
+ color: var(--white);
9052
+ cursor: pointer;
9053
+ }
9054
+
9055
+ .ao-play:focus-visible,
9056
+ .ao-speed:focus-visible {
9057
+ outline: 2px solid var(--blue);
9058
+ outline-offset: 2px;
9059
+ }
9060
+
9061
+ .ao-time {
9062
+ font-size: 0.8rem;
9063
+ color: var(--text-2);
9064
+ white-space: nowrap;
9065
+ }
9066
+
9067
+ .ao-seek {
9068
+ width: 100%;
9069
+ height: 4px;
9070
+ margin: 0;
9071
+ border-radius: 2px;
9072
+ background: linear-gradient(to right, var(--blue) var(--ao-fill, 0%), var(--line-strong) var(--ao-fill, 0%));
9073
+ appearance: none;
9074
+ cursor: pointer;
9075
+ }
9076
+
9077
+ .ao-seek::-webkit-slider-thumb {
9078
+ width: 14px;
9079
+ height: 14px;
9080
+ border-radius: 50%;
9081
+ background: var(--white);
9082
+ appearance: none;
9083
+ }
9084
+
9085
+ .ao-seek::-moz-range-thumb {
9086
+ width: 14px;
9087
+ height: 14px;
9088
+ border: 0;
9089
+ border-radius: 50%;
9090
+ background: var(--white);
9091
+ }
9092
+
9093
+ .ao-speed {
9094
+ min-width: 52px;
9095
+ height: 30px;
9096
+ padding: 0 10px;
9097
+ border: 1px solid var(--line-strong);
9098
+ border-radius: 999px;
9099
+ background: transparent;
9100
+ color: var(--text);
9101
+ font-size: 0.8rem;
9102
+ cursor: pointer;
9103
+ }
9104
+
9105
+ .ao-speed:hover {
9106
+ border-color: var(--blue-60);
9107
+ }
9108
+
9109
+ .ao-transcript-toggle {
9110
+ justify-self: start;
9111
+ }
9112
+
9113
+ .ao-card-actions {
9114
+ gap: 8px;
9115
+ }
9116
+
9117
+ /* Delete reads as a normal button (not dimmed), red when pointed at */
9118
+ .ao-delete:hover,
9119
+ .ao-delete:focus-visible {
9120
+ border-color: #ef4444;
9121
+ color: #ff8a8a;
9122
+ }
9123
+
9124
+ @media (prefers-reduced-motion: reduce) {
9125
+ .ao-card {
9126
+ transition: none;
9127
+ }
9128
+ }