Muhammad-Mehdi commited on
Commit
4a3d0a5
·
1 Parent(s): fa8df4c

slides editing added

Browse files
backend/app/db.py CHANGED
@@ -11,8 +11,13 @@ class Base(DeclarativeBase):
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
- _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else {}
15
- engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args)
 
 
 
 
 
16
 
17
  if _sqlite:
18
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
 
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
+ # Postgres: a connection that dies silently (laptop sleep, network change, Neon dropping it) otherwise leaves a query
15
+ # waiting on the socket forever, and the worker loop with it. Keepalives notice a dead peer within about a minute
16
+ # and the query fails instead; connect_timeout does the same for a connect that never completes.
17
+ _pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10,
18
+ "keepalives_count": 3}
19
+ _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args
20
+ engine = create_engine(settings.database_url, pool_pre_ping=True, pool_recycle=300, connect_args=_connect_args)
21
 
22
  if _sqlite:
23
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
backend/app/routers/artifacts.py CHANGED
@@ -19,7 +19,10 @@ 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"])
@@ -297,6 +300,45 @@ def slide_deck_file(artifact_id: uuid.UUID, ext: Literal["pdf", "pptx"], ctx: Ct
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)
 
19
  from app.services.storage import storage
20
  from app.services.usage import check_limit
21
  from app.slides import export as slide_export
22
+ from app.slides.build import BadEdit, restructure
23
+ from app.slides.build import slots as slide_slots
24
  from app.slides.build import stored as stored_deck
25
+ from app.slides.build import view as slide_pages
26
  from app.slides.themes import theme_id as slide_theme_id
27
 
28
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
 
300
  return Response(data, media_type=media, headers={"Content-Disposition": f'attachment; filename="{name}.{ext}"'})
301
 
302
 
303
+ class DeckIn(BaseModel):
304
+ # The deck's slides as the editor has them, in the stored shape (app.slides.content.empty_slide); coerced on arrival
305
+ slides: list[dict] = Field(min_length=1, max_length=60)
306
+
307
+
308
+ def _editable_deck(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
309
+ a = get_artifact_or_404(ctx, artifact_id)
310
+ if a.type != "slide_deck" or a.status != "ready":
311
+ raise HTTPException(400, "Only a finished slide deck can be edited.")
312
+ return a
313
+
314
+
315
+ @router.post("/{artifact_id}/deck-preview")
316
+ def preview_deck(artifact_id: uuid.UUID, body: DeckIn, ctx: Ctx = Depends(get_ctx)):
317
+ """The editor's slides drawn, without saving: after a slide is added, removed, moved or changed to another layout,
318
+ or a text box is added or removed. Returns the slides as they will be kept (variants given, text fitted)."""
319
+ a = _editable_deck(ctx, artifact_id)
320
+ try:
321
+ deck = restructure(a.content_json or {}, body.slides, final=False)
322
+ except BadEdit as exc:
323
+ raise HTTPException(400, str(exc)) from exc
324
+ content = {**(a.content_json or {}), "deck": deck}
325
+ return {"slides": deck["slides"], "slides_html": slide_pages(content), "slide_slots": slide_slots(content)}
326
+
327
+
328
+ @router.put("/{artifact_id}/deck")
329
+ def save_deck(artifact_id: uuid.UUID, body: DeckIn, ctx: Ctx = Depends(get_ctx)):
330
+ """The editor's slides kept: text, speaker notes, and which slides there are, in what order and layout. The deck is
331
+ fitted again (with the Chrome check), so the slides, the PDF and the PowerPoint all show it."""
332
+ a = _editable_deck(ctx, artifact_id)
333
+ try:
334
+ deck = restructure(a.content_json or {}, body.slides, final=True)
335
+ except BadEdit as exc:
336
+ raise HTTPException(400, str(exc)) from exc
337
+ a.content_json = {**(a.content_json or {}), "deck": deck}
338
+ ctx.db.commit()
339
+ return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
340
+
341
+
342
  @router.patch("/{artifact_id}")
343
  def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
344
  a = get_artifact_or_404(ctx, artifact_id)
backend/app/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
  from app.slides.build import view as slide_pages
13
 
14
  TYPE_LABELS = {
@@ -87,7 +88,7 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
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 []
 
9
  from app.models import Artifact, Job
10
  from app.services.jobs import create_job, serialize_job
11
  from app.services.storage import storage
12
+ from app.slides.build import slots as slide_slots
13
  from app.slides.build import view as slide_pages
14
 
15
  TYPE_LABELS = {
 
88
 
89
  content = {**content, "html": infographic_html(content), "html_landscape": infographic_html(content, "landscape")}
90
  if a.type == "slide_deck" and a.status == "ready":
91
+ content = {**content, "slides_html": slide_pages(content), "slide_slots": slide_slots(content)}
92
  if a.type == "report" and any(b.get("type") == "infographic" for b in content.get("blocks") or []):
93
  content = {**content, "blocks": report_blocks(a)}
94
  slides = content.get("slide_keys") or []
backend/app/slides/build.py CHANGED
@@ -1,14 +1,17 @@
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
 
@@ -23,11 +26,17 @@ def fit(deck: dict, theme: str, fmt: str, seed: int) -> dict:
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:
@@ -45,6 +54,70 @@ def finish(deck: dict, theme: str, fmt: str, seed: int, previous: list[tuple[str
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"
 
1
  """From a written deck to what is stored and shown. `finish` picks each slide's variant, fits its text, and checks
2
+ the result in Chrome when one is installed. `restructure` takes a deck back from the editor and fits it again.
3
+ `view` reads a stored deck back (clamped again, so older or edited decks still draw) and gives its pages; one slide
4
+ that cannot be drawn becomes a plain one instead of breaking the deck."""
5
 
6
  import copy
7
  import logging
8
+ import random
9
+ from collections import Counter
10
 
11
  from app.infographics.image import ImageUnavailable
12
  from app.slides import export
13
  from app.slides.content import clamp_deck, clamp_slide, empty_slide
14
+ from app.slides.layout import GEOMETRY, SCALES, VARIANTS, assign_variants, compose, fits_variant, seed_of
15
  from app.slides.render import slide_html
16
  from app.slides.themes import theme_id
17
 
 
26
 
27
 
28
  def finish(deck: dict, theme: str, fmt: str, seed: int, previous: list[tuple[str, str]] | None = None) -> dict:
29
+ """Variants, then refit. Raises ValueError when a slide cannot be drawn at all."""
 
30
  theme = theme_id(theme)
31
  deck = copy.deepcopy(deck)
32
  assign_variants(deck["slides"], theme, seed, previous)
33
+ return refit(deck, theme, fmt, seed)
34
+
35
+
36
+ def refit(deck: dict, theme: str, fmt: str, seed: int) -> dict:
37
+ """Fitted text and a Chrome check that no text box overflows (slides that do are drawn a step smaller and fitted
38
+ again), keeping each slide's variant. Raises ValueError when a slide cannot be drawn at all."""
39
+ theme = theme_id(theme)
40
  deck = fit(deck, theme, fmt, seed)
41
  for _ in range(3):
42
  try:
 
54
  return deck
55
 
56
 
57
+ class BadEdit(ValueError):
58
+ """A deck sent back from the editor that cannot be kept (no slides, or too many)."""
59
+
60
+
61
+ MAX_SLIDES = 30
62
+
63
+
64
+ def fill_variants(slides: list[dict], theme: str, seed: int) -> None:
65
+ """Give a variant, in place, to each slide that has none or whose variant no longer suits it (a new slide, a changed
66
+ layout, a card row now with five points). Every other slide keeps its own, so editing one slide never redraws the
67
+ rest. The choice follows assign_variants: suited to the content, not the same as a neighbour, unused ones first."""
68
+ theme = theme_id(theme)
69
+ def ok(i: int) -> bool:
70
+ s = slides[i]
71
+ return s["variant"] in VARIANTS[s["layout"]] and fits_variant(s, theme, s["variant"])
72
+ keep = [ok(i) for i in range(len(slides))]
73
+ for i, s in enumerate(slides):
74
+ before = (slides[i - 1]["layout"], slides[i - 1]["variant"]) if i else None
75
+ if keep[i] and (s["layout"], s["variant"]) != before:
76
+ continue
77
+ after = (slides[i + 1]["layout"], slides[i + 1]["variant"]) if i + 1 < len(slides) and keep[i + 1] else None
78
+ options = [v for v in VARIANTS[s["layout"]] if fits_variant(s, theme, v)] or list(VARIANTS[s["layout"]])
79
+ fresh = [v for v in options if (s["layout"], v) not in (before, after)] or options
80
+ used = Counter((x["layout"], x["variant"]) for j, x in enumerate(slides) if j != i)
81
+ rnd = random.Random(seed + i)
82
+ s["variant"] = min(fresh, key=lambda v: (used[(s["layout"], v)], rnd.random()))
83
+ keep[i] = True
84
+
85
+
86
+ def restructure(content: dict, slides: object, final: bool) -> dict:
87
+ """The stored deck with the slides the editor sent (text edited, slides added, removed, moved, or changed to another
88
+ layout). They are coerced like model output (clamp_deck: plain text, the format's limits, valid layouts), slides
89
+ that need one get a variant, and the deck is fitted: quickly for a preview, with the Chrome check when `final`."""
90
+ got = stored(content)
91
+ if not got:
92
+ raise BadEdit("This deck has no slides to edit.")
93
+ deck, theme, fmt, seed = got
94
+ if not isinstance(slides, list) or not slides:
95
+ raise BadEdit("A deck needs at least one slide.")
96
+ if len(slides) > MAX_SLIDES:
97
+ raise BadEdit(f"A deck can have at most {MAX_SLIDES} slides.")
98
+ new = clamp_deck({"title": deck["title"], "subtitle": deck["subtitle"], "slides": slides}, fmt)
99
+ if not new:
100
+ raise BadEdit("A deck needs at least one slide with a heading.")
101
+ for s in new["slides"]:
102
+ s["shrink"] = 0 # text changed: fitted afresh, so shorter text can be drawn larger again
103
+ fill_variants(new["slides"], theme, seed)
104
+ return refit(new, theme, fmt, seed) if final else fit(new, theme, fmt, seed)
105
+
106
+
107
+ def slots(content: dict) -> list[list[str]]:
108
+ """For each slide, the parts its variant has a place for (kicker, lead, body, by, closing...): what the editor
109
+ can offer to add."""
110
+ got = stored(content)
111
+ if not got:
112
+ return []
113
+ deck, theme, _fmt, _seed = got
114
+ out = []
115
+ for s in deck["slides"]:
116
+ variants = GEOMETRY[theme][s["layout"]]
117
+ out.append(sorted(k for k, v in (variants.get(s["variant"]) or next(iter(variants.values()))).items() if v))
118
+ return out
119
+
120
+
121
  def stored(content: dict) -> tuple[dict, str, str, int] | None:
122
  """A stored deck, clamped, with its theme, format and seed (None when there is none)."""
123
  fmt = content.get("format") if content.get("format") in ("detailed", "presenter") else "detailed"
backend/app/slides/content.py CHANGED
@@ -111,6 +111,23 @@ def _column(raw: object, lim: dict) -> dict:
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": []},
 
111
  return {"label": clip(c.get("label"), lim["label"]), "items": items}
112
 
113
 
114
+ _LIMIT_KEY = {"kicker": "kicker", "heading": "heading", "lead": "lead", "closing": "closing", "stat.value": "value",
115
+ "stat.label": "stat_label", "quote.text": "quote", "quote.by": "by", "left.label": "label",
116
+ "right.label": "label", "left.items": "item", "right.items": "item", "points.term": "term",
117
+ "points.text": "text", "takeaways": "takeaway"}
118
+
119
+
120
+ def field_limit(layout: str, field: str, fmt: str) -> int:
121
+ """How many characters `field` may hold on a slide of this layout: what clamp_slide cuts it to."""
122
+ lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
123
+ key = re.sub(r"\.\d+", "", field) # "points.2.text" -> "points.text"
124
+ if layout == "title" and key in ("heading", "lead"):
125
+ return lim["title" if key == "heading" else "subtitle"]
126
+ if layout == "agenda" and key == "points.text":
127
+ return lim["agenda"]
128
+ return lim[_LIMIT_KEY[key]]
129
+
130
+
131
  def empty_slide(layout: str = "points") -> dict:
132
  return {"layout": layout, "variant": "", "kicker": "", "heading": "", "lead": "", "points": [],
133
  "left": {"label": "", "items": []}, "right": {"label": "", "items": []},
backend/app/slides/decor.py CHANGED
@@ -12,7 +12,7 @@ from app.slides.themes import H, W
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
@@ -24,6 +24,10 @@ class Shape:
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
@@ -73,6 +77,38 @@ def _constellation(rnd: random.Random, box: tuple[float, float, float, float], 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"
@@ -91,8 +127,8 @@ def build(theme: str, layout: str, variant: str, seed: int, index: int) -> Decor
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),
@@ -114,32 +150,29 @@ def build(theme: str, layout: str, variant: str, seed: int, index: int) -> Decor
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)]
 
12
 
13
  @dataclass
14
  class Shape:
15
+ kind: str # glow | planet | ring | band | const | stars
16
  cx: float = 0
17
  cy: float = 0
18
  r: float = 0
 
24
  width: float = 2.0
25
  points: list[tuple[float, float, float, float]] = field(default_factory=list) # stars: x, y, r, opacity
26
  edges: list[tuple[int, int]] = field(default_factory=list)
27
+ # ring, band: only this part of the path, in degrees round it (0 to 180 is the half nearer the viewer, below the
28
+ # centre before the tilt). None: all of it.
29
+ arc: tuple[float, float] | None = None
30
+ inner: float = 0 # band: the inner edge, as a share of rx and ry (the band fills from there out to the ring)
31
 
32
 
33
  @dataclass
 
77
  return Shape("const", points=pts, edges=edges, color="accent", opacity=0.85)
78
 
79
 
80
+ def on_ring(ring: Shape, degrees: float) -> tuple[float, float]:
81
+ """The point at `degrees` round a ring's path, after its tilt: where a planet sits so it is on the drawn line.
82
+ Same maths as the renderers (SVG rotate(rot cx cy), the PowerPoint's polyline)."""
83
+ a, rot = math.radians(degrees), math.radians(ring.rot)
84
+ x, y = ring.rx * math.cos(a), ring.ry * math.sin(a)
85
+ return ring.cx + x * math.cos(rot) - y * math.sin(rot), ring.cy + x * math.sin(rot) + y * math.cos(rot)
86
+
87
+
88
+ def ring_path(s: Shape, scale: float = 1.0, steps: int = 180) -> list[tuple[float, float]]:
89
+ """Points along a ring's (scaled) path, over its arc, for the renderers to draw."""
90
+ a0, a1 = s.arc or (0, 360)
91
+ scaled = Shape("ring", s.cx, s.cy, rx=s.rx * scale, ry=s.ry * scale, rot=s.rot)
92
+ return [on_ring(scaled, a0 + (a1 - a0) * k / steps) for k in range(steps + 1)]
93
+
94
+
95
+ # A ringed planet's band, from the inside out: (inner, outer, opacity) as shares of the ring. A brighter inner ring
96
+ # and a fainter outer one with a dark gap between, as Saturn's are.
97
+ BANDS = ((0.72, 0.86, 0.34), (0.89, 1.0, 0.22))
98
+ NEAR, FAR = (0, 180), (180, 360)
99
+
100
+
101
+ def ringed_planet(cx: float, cy: float, r: float, rx: float, ry: float, rot: float, opacity: float = 1.0) -> list[Shape]:
102
+ """A planet inside its rings: the far half of the rings goes behind the planet and the near half in front, so the
103
+ rings wrap round it instead of lying flat over its face. The far half is a little dimmer, being further away."""
104
+ def half(arc: tuple[float, float], op: float) -> list[Shape]:
105
+ out = [Shape("band", cx, cy, rx=rx * hi, ry=ry * hi, rot=rot, color="accent", opacity=o * op, arc=arc, inner=lo / hi)
106
+ for lo, hi, o in BANDS]
107
+ out.append(Shape("ring", cx, cy, rx=rx, ry=ry, rot=rot, color="accent", opacity=0.8 * op, width=2, arc=arc))
108
+ return out
109
+ return [*half(FAR, 0.6 * opacity), Shape("planet", cx, cy, r), *half(NEAR, opacity)]
110
+
111
+
112
  def build(theme: str, layout: str, variant: str, seed: int, index: int) -> Decor:
113
  """The drawing for one slide."""
114
  dark = theme == "dark-space"
 
127
  elif key == ("title", "b"):
128
  cx, cy = 1520 + j(60), 300 + j(50)
129
  hot = (cx, cy)
130
+ shapes += [Shape("glow", cx, cy, 520, color="accent2", opacity=0.5),
131
+ *ringed_planet(cx, cy, 230 + j(25), rx=395, ry=82, rot=-18 + j(6))]
132
  elif key == ("title", "c"):
133
  hot = (1450, 540)
134
  shapes += [Shape("glow", 1450, 540, 620, color="accent2", opacity=0.35),
 
150
  x0, y0, x1, y1 = free
151
  r = min(x1 - x0, y1 - y0) * rnd.uniform(0.26, 0.34)
152
  cx, cy = rnd.uniform(x0 + r, x1 - r), rnd.uniform(y0 + r, y1 - r)
153
+ shapes += [Shape("glow", cx, cy, r * 2.2, color="accent2", opacity=0.35),
154
+ *ringed_planet(cx, cy, r, rx=r * 1.8, ry=r * 0.38, rot=-16 + j(10), opacity=0.9)]
155
  else:
156
  ring = lambda cx, cy, r, op=0.3: Shape("ring", cx, cy, rx=r, ry=r, color="accent2", opacity=op, width=2) # noqa: E731
157
  if key == ("title", "a"):
158
  cx, cy = 2010 + j(40), 540 + j(60)
159
  hot = (1500, 540)
160
+ orbit = ring(cx, cy, 540, 0.24)
161
+ shapes += [ring(cx, cy, 360), orbit, ring(cx, cy, 740, 0.18), Shape("planet", *on_ring(orbit, 200 + j(20)), 34)]
 
162
  elif key == ("title", "b"):
163
  hot = (1500, 820)
164
  shapes += [Shape("glow", 1560, 840, 520, color="accent", opacity=0.12),
165
  _constellation(rnd, (1360, 660, 1800, 980), 7)]
166
  elif key == ("title", "c"):
167
  hot = (960, 540)
168
+ rot = -10 + j(4) # one tilt for both, so the rings stay concentric
169
+ orbit = Shape("ring", 960, 520, rx=900, ry=330, rot=rot, color="accent2", opacity=0.28, width=2.5)
170
+ shapes += [orbit, Shape("ring", 960, 520, rx=1040, ry=400, rot=rot, color="accent2", opacity=0.14, width=2),
171
+ Shape("planet", *on_ring(orbit, rnd.choice((20, 160, 200, 340)) + j(10)), 30)]
172
+ elif key in (("section", "a"), ("section", "b")):
173
+ cx, toward = (1960, 180) if variant == "a" else (-40, 0) # the planet sits on the side facing the text
174
+ orbit = ring(cx, 560, 460, 0.22)
175
+ shapes += [ring(cx, 560, 300), orbit, ring(cx, 560, 640, 0.15), Shape("planet", *on_ring(orbit, toward + j(10)), 26)]
 
 
176
  elif key == ("closing", "c"):
177
  hot = (960, 860)
178
  shapes += [Shape("ring", 960, 540, rx=1000, ry=470, rot=6 + j(4), color="accent2", opacity=0.2, width=2.5)]
backend/app/slides/export.py CHANGED
@@ -22,9 +22,9 @@ 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
@@ -144,6 +144,10 @@ def background(decor: Decor, pal: Palette, dark: bool) -> bytes:
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()
@@ -174,6 +178,10 @@ def _draw(sky: Image.Image, layer: Image.Image, draw: ImageDraw.ImageDraw, s: Sh
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)
@@ -191,6 +199,25 @@ def _draw(sky: Image.Image, layer: Image.Image, draw: ImageDraw.ImageDraw, s: Sh
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)
 
22
  from pptx.util import Emu, Pt
23
 
24
  from app.infographics.image import ImageUnavailable, chrome
25
+ from app.slides.decor import Decor, Shape, build, ring_path
26
  from app.slides.layout import El, compose
27
+ from app.slides.render import SHADE, color, deck_print_html, planet_colors, ring_shade
28
  from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
29
 
30
  TIMEOUT = 120
 
144
  layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
145
  draw = ImageDraw.Draw(layer)
146
  for s in decor.shapes:
147
+ if s.kind in ("glow", "planet", "band"): # pasted straight onto the sky: put down the lines drawn so far
148
+ sky.alpha_composite(layer) # first, so the order is the page's (a ring's far half behind its planet)
149
+ layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
150
+ draw = ImageDraw.Draw(layer)
151
  _draw(sky, layer, draw, s, pal, dark)
152
  sky = Image.alpha_composite(sky, layer).convert("RGB")
153
  buf = io.BytesIO()
 
178
  disc.putalpha(mask)
179
  _paste_clipped(sky, disc, s.cx - s.r, s.cy - s.r)
180
  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)
181
+ elif s.kind == "band":
182
+ sky.alpha_composite(_band(s, c))
183
+ elif s.kind == "ring" and s.arc:
184
+ draw.line(ring_path(s), fill=c + (int(255 * s.opacity),), width=max(1, round(s.width)), joint="curve")
185
  elif s.kind == "ring":
186
  steps = 360
187
  rot = math.radians(s.rot)
 
199
  draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * s.opacity),))
200
 
201
 
202
+ def _band(s: Shape, c: tuple[int, int, int]) -> Image.Image:
203
+ """A ringed planet's band as a slide-sized layer: the shape filled, faded left to right as the page's SHADE."""
204
+ shape = Image.new("L", (W, H), 0)
205
+ ImageDraw.Draw(shape).polygon(ring_path(s) + ring_path(s, s.inner)[::-1], fill=255)
206
+ lo, hi = ring_shade(s)
207
+ ramp = []
208
+ for x in range(W):
209
+ t = min(1.0, max(0.0, (x - lo) / (hi - lo))) if hi > lo else 0.0
210
+ (o0, a0), (o1, a1) = next((SHADE[k], SHADE[k + 1]) for k in range(len(SHADE) - 1) if t <= SHADE[k + 1][0])
211
+ ramp.append(int(255 * s.opacity * (a0 + (a1 - a0) * (t - o0) / (o1 - o0))))
212
+ fade = Image.new("L", (W, 1))
213
+ fade.putdata(ramp)
214
+ alpha = Image.new("L", (W, H), 0)
215
+ alpha.paste(fade.resize((W, H)), mask=shape)
216
+ out = Image.new("RGBA", (W, H), c + (0,))
217
+ out.putalpha(alpha)
218
+ return out
219
+
220
+
221
  def _paste_clipped(base: Image.Image, img: Image.Image, x: float, y: float) -> None:
222
  """alpha_composite `img` onto `base` at (x, y), clipping whatever falls outside."""
223
  x, y = int(x), int(y)
backend/app/slides/layout.py CHANGED
@@ -18,8 +18,8 @@ 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": {
@@ -160,7 +160,7 @@ def block(theme: str, layout: str, variant: str, name: str) -> tuple | None:
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"])
@@ -187,7 +187,7 @@ def assign_variants(slides: list[dict], theme: str, seed: int, previous: list[tu
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
@@ -219,6 +219,7 @@ 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:
@@ -291,16 +292,16 @@ 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
 
@@ -341,10 +342,11 @@ def _text(role: str, rect: tuple, paras: list[list[Run]], base: float, start: in
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:
@@ -354,13 +356,13 @@ def _body(els: list[El], s: dict, rect: tuple, kind: str, fmt: str, t: dict, sta
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)
@@ -466,15 +468,16 @@ def _body(els: list[El], s: dict, rect: tuple, kind: str, fmt: str, t: dict, sta
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":
@@ -548,7 +551,7 @@ def _columns(els: list[El], s: dict, rects: tuple[tuple, tuple], t: dict, start:
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:
@@ -558,13 +561,14 @@ def _columns(els: list[El], s: dict, rects: tuple[tuple, tuple], t: dict, start:
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
 
@@ -592,13 +596,13 @@ def _compose_once(s: dict, deck: dict, index: int, theme: str, fmt: str, start:
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")
@@ -609,20 +613,20 @@ def _compose_once(s: dict, deck: dict, index: int, theme: str, fmt: str, start:
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)
@@ -636,11 +640,11 @@ def _compose_once(s: dict, deck: dict, index: int, theme: str, fmt: str, start:
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":
 
18
 
19
  # --- geometry -------------------------------------------------------------------------------------------------------
20
  # A block is (x, y, w, h[, align[, valign[, kind]]]). `kind` says how a body, a column or the closing line is drawn.
21
+ # Night stellar (dark-space): cinematic, centred openers and closers, asymmetric splits. Moon light (light-space):
22
+ # editorial, left aligned, top anchored, mirrored splits.
23
 
24
  GEOMETRY: dict[str, dict[str, dict[str, dict[str, tuple]]]] = {
25
  "dark-space": {
 
160
 
161
  # --- picking variants -----------------------------------------------------------------------------------------------
162
 
163
+ def fits_variant(slide: dict, theme: str, variant: str) -> bool:
164
  """Whether a variant suits this slide's content (a card row needs 3 or 4 points, a timeline 3 to 5)."""
165
  layout = slide["layout"]
166
  n = len(slide["points"])
 
187
  before: tuple[str, str] | None = None
188
  previous = previous or []
189
  for i, s in enumerate(slides):
190
+ options = [v for v in VARIANTS[s["layout"]] if fits_variant(s, theme, v)] or list(VARIANTS[s["layout"]])
191
  fresh = [v for v in options if (s["layout"], v) != before]
192
  if not fresh: # the only variant that suits it was just used: any other one draws it too, so never repeat
193
  fresh = [v for v in VARIANTS[s["layout"]] if (s["layout"], v) != before] or options
 
219
  text: str
220
  bold: bool = False
221
  color: str = "ink"
222
+ field: str = "" # where the text lives in the slide ("heading", "points.2.text"), so the page can edit it; "" is ours
223
 
224
 
225
  def _lines(runs: list[Run], width: float, size: float, font: str, upper: bool, track: float) -> int:
 
292
  """Scales to try, largest first: from `start` down, with the growing ones first when allowed and not shrunk."""
293
  return (GROW + SCALES) if grow and start == 0 else SCALES[start:]
294
 
295
+ # Base font sizes in px, by format (both themes). Presenter slides carry fewer words, so they are drawn larger.
296
  TYPE = {
297
+ "detailed": {"kicker": 24, "heading": 66, "title": 110, "section": 88, "lead": 33, "title_lead": 37, "point": 31,
298
+ "card_term": 33, "card_text": 28, "value": 242, "label": 37, "quote": 57, "by": 26, "take": 31,
299
+ "closing": 51, "closing_big": 73, "agenda": 35, "col_label": 31, "col_item": 30, "num": 24,
300
+ "footer": 20},
301
+ "presenter": {"kicker": 26, "heading": 79, "title": 123, "section": 101, "lead": 40, "title_lead": 42,
302
+ "point": 44, "card_term": 37, "card_text": 37, "value": 286, "label": 44, "quote": 66, "by": 29,
303
+ "take": 40, "closing": 62, "closing_big": 86, "agenda": 40, "col_label": 33, "col_item": 40,
304
+ "num": 26, "footer": 20},
305
  }
306
 
307
 
 
342
  upper=upper, track=track, gap=size * gap_em)
343
 
344
 
345
+ def _point_runs(p: dict, i: int, fmt: str) -> list[Run]:
346
  if p["term"] and fmt == "detailed":
347
+ return [Run(p["term"], True, "ink", f"points.{i}.term"), Run(". ", True, "ink"),
348
+ Run(p["text"], False, "mute", f"points.{i}.text")]
349
+ return [Run(p["text"], False, "ink", f"points.{i}.text")]
350
 
351
 
352
  def _body(els: list[El], s: dict, rect: tuple, kind: str, fmt: str, t: dict, start: int, dark: bool) -> None:
 
356
 
357
  if kind in ("list", "list2", "agenda_list", "agenda_grid", "take_stack"):
358
  if kind in ("agenda_list", "agenda_grid"):
359
+ items = [[Run(p["text"], False, "ink", f"points.{i}.text")] for i, p in enumerate(pts)]
360
  base, numbered = t["agenda"], True
361
  elif kind == "take_stack":
362
+ items = [[Run(tx, False, "ink", f"takeaways.{i}")] for i, tx in enumerate(s["takeaways"])]
363
  base, numbered = t["take"], True
364
  else:
365
+ items = [_point_runs(p, i, fmt) for i, p in enumerate(pts)]
366
  base, numbered = t["point"], False
367
  cols = 2 if kind in ("list2", "agenda_grid") and len(items) >= 3 else 1
368
  per = math.ceil(len(items) / cols)
 
468
  align = "center" if kind == "take_arc" else "left"
469
  if term:
470
  th = text_height([[Run(term, True)]], inner_w, ts, 1.15, "display")
471
+ els.append(El("text", "term", cx + pad, cy, inner_w, th + 2, paras=[[Run(term, True, "ink", f"points.{i}.term")]], size=ts,
472
  lh=1.15, font="display", align=align))
473
  cy += th + ts * 0.5
474
+ where = f"points.{i}.text" if kind == "cards" else f"takeaways.{i}"
475
+ els.append(El("text", "item", cx + pad, cy, inner_w, y + h - pad - cy,
476
+ paras=[[Run(text, False, "mute" if term else "ink", where)]], size=xs, lh=1.4, align=align))
477
  return
478
 
479
  if kind in ("timeline_v", "timeline_h"):
480
+ items = [_point_runs(p, i, fmt) for i, p in enumerate(pts)]
481
  n = len(items)
482
  r = 28
483
  if kind == "timeline_v":
 
551
  else:
552
  raise NoFit(("left", "right")[bad[0]] + "_items")
553
  panel_h = max(need) + 2 * pad + top + 8
554
+ for side, col, (x, y, w, h, *_) in zip(("left", "right"), sides, rects, strict=True):
555
  if kind == "panel":
556
  els.append(El("rect", "panel", x, y, w, min(h, panel_h), fill="card", stroke="cardline", stroke_w=2, radius=22))
557
  else:
 
561
  _, heights = measure(col, iw, ls, xs)
562
  if col["label"]:
563
  lab_h = text_height([[Run(col["label"], True)]], iw, ls, 1.2, "display")
564
+ els.append(El("text", "col_label", ix, cy, iw, lab_h + 2, paras=[[Run(col["label"], True, "accent", f"{side}.label")]],
565
  size=ls, lh=1.2, font="display"))
566
  cy += lab_h + xs * 0.9
567
+ for n, (item, hh) in enumerate(zip(col["items"], heights, strict=True)):
568
  d = xs * 0.32
569
  els.append(El("circle", "dot", ix + 2, cy + xs * 0.69 - d / 2, d, d, fill="accent"))
570
+ els.append(El("text", "item", ix + 40, cy, iw - 40, hh + 2, paras=[[Run(item, False, "ink", f"{side}.items.{n}")]],
571
+ size=xs, lh=1.38))
572
  cy += hh + xs * 0.75
573
 
574
 
 
596
  x, y, w, h, align, *_ = k
597
  bar_x = x + (w - 64) / 2 if align == "center" else (x + w - 64 if align == "right" else x)
598
  els.append(El("rect", "bar", bar_x, y - 18, 64, 5, fill="accent"))
599
+ els.append(_text("kicker", k, [[Run(s["kicker"], True, "accent", "kicker")]], t["kicker"], 0, font="mono", lh=1.2,
600
  upper=True, track=0.16, field_name="kicker"))
601
 
602
  heading_base = {"title": t["title"], "section": t["section"]}.get(layout, t["heading"])
603
  hr = rect("heading")
604
  if hr and s["heading"]:
605
+ els.append(_text("heading", hr, [[Run(s["heading"], True, "ink", "heading")]], heading_base, start, font="display",
606
  lh=1.08, field_name="heading"))
607
 
608
  lr = rect("lead")
 
613
  # Under a top aligned heading in the same column: follow the heading instead of waiting at the box's top.
614
  top = head.y + head.text_height() + head.size * 0.55
615
  lr = (lr[0], top, lr[2], lr[1] + lr[3] - top, *lr[4:])
616
+ els.append(_text("lead", lr, [[Run(s["lead"], False, "mute", "lead")]], base, start, lh=1.4, field_name="lead"))
617
 
618
  if layout == "stat":
619
+ els.append(_text("value", rect("value"), [[Run(s["stat"]["value"], True, "accent", "stat.value")]], t["value"], start,
620
  font="display", lh=1.0, field_name="stat_value"))
621
+ els.append(_text("label", rect("label"), [[Run(s["stat"]["label"], False, "ink", "stat.label")]], t["label"], start, lh=1.35,
622
  field_name="stat_label", grow=True))
623
  if layout == "quote":
624
  q = rect("quote")
625
+ els.append(_text("quote", q, [[Run("“", True, "accent"), Run(s["quote"]["text"], False, "ink", "quote.text"),
626
  Run("”", True, "accent")]], t["quote"], start, font="display", lh=1.22,
627
  field_name="quote", grow=True))
628
  if s["quote"]["by"] and (b := rect("by")):
629
+ els.append(_text("by", b, [[Run(s["quote"]["by"], False, "mute", "quote.by")]], t["by"], 0, font="mono", lh=1.3,
630
  upper=True, track=0.08, field_name="by"))
631
  if layout == "two_column":
632
  _columns(els, s, (rect("left"), rect("right")), t, start)
 
640
  x, y, w, h, *_ = cr
641
  els.append(El("rect", "band", x, y, w, h, fill="band", radius=24))
642
  inner = (x + 60, y, w - 120, h, "left", "middle")
643
+ els.append(_text("closing", inner, [[Run(s["closing"], True, "bandink", "closing")]], t["closing"], start,
644
  font="display", lh=1.15, field_name="closing"))
645
  else:
646
  base = t["closing_big"] if kind == "big" else t["closing"]
647
+ els.append(_text("closing", cr, [[Run(s["closing"], True, "ink", "closing")]], base, start, font="display", lh=1.12,
648
  field_name="closing", grow=kind == "big"))
649
 
650
  if layout != "title":
backend/app/slides/render.py CHANGED
@@ -1,10 +1,13 @@
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"
@@ -35,8 +38,8 @@ html,body{{margin:0;padding:0}}
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
 
@@ -44,14 +47,14 @@ 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],
@@ -72,6 +75,11 @@ def _el(e: El, pal: Palette, layout: str) -> str:
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):
@@ -97,6 +105,16 @@ def _shape(s: Shape, pal: Palette, dark: bool, gid: str, defs: list[str]) -> str
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})"/>')
@@ -109,16 +127,30 @@ def _shape(s: Shape, pal: Palette, dark: bool, gid: str, defs: list[str]) -> str
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
 
@@ -131,14 +163,14 @@ def _page(body: str, extra_css: str = "") -> str:
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}")
 
1
  """A deck as HTML: one standalone page per slide (shown in the app in a sandboxed frame, scaled to fit) and one page
2
  with every slide (printed to PDF). Every word from the model is escaped; the markup is all ours."""
3
 
4
+ import math
5
+
6
  from markupsafe import escape
7
 
8
+ from app.slides.content import field_limit
9
+ from app.slides.decor import Decor, Shape, build, ring_path
10
+ from app.slides.layout import Composed, El, Run, compose
11
  from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
12
 
13
  FONT_LINK = ("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;700"
 
38
  .dark .r-heading.l-title,.dark .r-value{{text-shadow:0 0 48px rgba(33,124,255,.45)}}
39
  .dark .r-marker{{box-shadow:0 0 36px rgba(33,124,255,.75)}}
40
  .dark .r-bar{{box-shadow:0 0 18px rgba(77,148,255,.8)}}
41
+ .light .r-card,.light .r-panel{{box-shadow:0 18px 44px rgba(11,26,58,.14)}}
42
+ .light .r-band{{box-shadow:0 18px 44px rgba(11,26,58,.26)}}
43
  """
44
 
45
 
 
47
  return ";".join(f"{k.replace('_', '-')}:{v}" for k, v in kw.items() if v is not None)
48
 
49
 
50
+ def _el(e: El, pal: Palette, layout: str, fmt: str) -> str:
51
  box = dict(left=f"{e.x:.1f}px", top=f"{e.y:.1f}px", width=f"{e.w:.1f}px", height=f"{e.h:.1f}px")
52
  if e.kind == "text":
53
  paras = []
54
  for i, p in enumerate(e.paras):
55
  runs = "".join(
56
+ f'<span{_editable(r, layout, fmt)} style="color:{color(pal, r.color)}{";font-weight:700" if r.bold else ""}">'
57
+ f'{escape(r.text)}</span>' for r in p)
58
  gap = f' style="margin-top:{e.gap:.1f}px"' if i and e.gap else ""
59
  paras.append(f"<p{gap}>{runs}</p>")
60
  css = _style(**box, font_size=f"{e.size}px", line_height=str(e.lh), font_family=_FAMILY[e.font],
 
75
  return f'<div class="e r-{e.role}" style="{css}"></div>'
76
 
77
 
78
+ def _editable(r: Run, layout: str, fmt: str) -> str:
79
+ """Where a run's text lives and how long it may be, for the page's editor (none on our own text: numbers, footers)."""
80
+ return f' data-f="{r.field}" data-max="{field_limit(layout, r.field, fmt)}"' if r.field else ""
81
+
82
+
83
  def _svg(d: Decor, pal: Palette, dark: bool, uid: str) -> str:
84
  defs, out = [], []
85
  for n, s in enumerate(d.shapes):
 
105
  f'<stop offset="1" stop-color="{lo}"/></radialGradient>')
106
  return (f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
107
  f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="none" stroke="{rim}" stroke-width="3" opacity=".7"/>')
108
+ if s.kind == "band":
109
+ lo, hi = ring_shade(s)
110
+ defs.append(f'<linearGradient id="{gid}" gradientUnits="userSpaceOnUse" x1="{lo:.0f}" y1="0" x2="{hi:.0f}" y2="0">'
111
+ + "".join(f'<stop offset="{o}" stop-color="{c}" stop-opacity="{a * s.opacity:.3f}"/>' for o, a in SHADE)
112
+ + "</linearGradient>")
113
+ pts = ring_path(s) + ring_path(s, s.inner)[::-1]
114
+ return f'<path d="M{_pts(pts)}Z" fill="url(#{gid})"/>'
115
+ if s.kind == "ring" and s.arc:
116
+ return (f'<path d="M{_pts(ring_path(s))}" fill="none" stroke="{c}" stroke-width="{s.width}" '
117
+ f'stroke-linecap="round" opacity="{s.opacity}"/>')
118
  if s.kind == "ring":
119
  return (f'<ellipse cx="{s.cx:.0f}" cy="{s.cy:.0f}" rx="{s.rx:.0f}" ry="{s.ry:.0f}" fill="none" stroke="{c}" '
120
  f'stroke-width="{s.width}" opacity="{s.opacity}" transform="rotate({s.rot:.1f} {s.cx:.0f} {s.cy:.0f})"/>')
 
127
  return ""
128
 
129
 
130
+ # How brightly a ringed planet's band shows across its width, left to right: lit from the upper left, as the planet is.
131
+ SHADE = ((0, 1.0), (0.45, 0.8), (1, 0.3))
132
+
133
+
134
+ def ring_shade(s: Shape) -> tuple[float, float]:
135
+ """The band's left and right extent on the slide, which SHADE runs across."""
136
+ half = math.hypot(s.rx * math.cos(math.radians(s.rot)), s.ry * math.sin(math.radians(s.rot)))
137
+ return s.cx - half, s.cx + half
138
+
139
+
140
+ def _pts(pts: list[tuple[float, float]]) -> str:
141
+ return "L".join(f"{x:.1f} {y:.1f}" for x, y in pts)
142
+
143
+
144
  def planet_colors(pal: Palette, dark: bool) -> tuple[str, str, str]:
145
  """Lit side, dark side and rim of a planet."""
146
  return ("#1d4fb3", "#020713", pal.accent) if dark else ("#5d9bff", pal.accent2, pal.accent)
147
 
148
 
149
+ def slide_body(c: Composed, deck: dict, index: int, theme: str, fmt: str) -> str:
150
  t = THEMES[theme]
151
  s = c.slide
152
  d = build(theme, s["layout"], s["variant"], c.seed, index)
153
+ els = "".join(_el(e, t.pal, s["layout"], fmt) for e in c.elements)
154
  cls = f"slide {'dark' if t.dark else 'light'} l-{s['layout']} v-{s['variant']}"
155
  return f'<div class="{cls}" style="background:{t.pal.bg1}">{_svg(d, t.pal, t.dark, f"s{index}g")}{els}</div>'
156
 
 
163
  def slide_html(deck: dict, index: int, theme: str, fmt: str, seed: int) -> str:
164
  """One slide as a standalone page."""
165
  theme = theme_id(theme)
166
+ return _page(slide_body(compose(deck, index, theme, fmt, seed), deck, index, theme, fmt),
167
  f"body{{background:{THEMES[theme].pal.bg1}}}")
168
 
169
 
170
  def deck_print_html(deck: dict, theme: str, fmt: str, seed: int, script: str = "") -> str:
171
  """Every slide on one page, one per printed page."""
172
  theme = theme_id(theme)
173
+ body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme, fmt) for i in range(len(deck["slides"])))
174
  css = (f"@page{{size:{W}px {H}px;margin:0}}body{{background:{THEMES[theme].pal.bg1}}}"
175
  ".slide{break-after:page;page-break-after:always}.slide:last-child{break-after:auto;page-break-after:auto}"
176
  "*{-webkit-print-color-adjust:exact;print-color-adjust:exact}")
backend/app/slides/themes.py CHANGED
@@ -31,12 +31,12 @@ class Theme:
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
 
 
31
 
32
 
33
  THEMES: dict[str, Theme] = {t.id: t for t in (
34
+ Theme("dark-space", "Night stellar", True, Palette(
35
  bg1="#000000", bg2="#0a1736", ink="#ffffff", mute="#b4bccc", accent="#4d94ff", accent2="#217cff",
36
  card="#0b1326", cardline="#1f3156", line="#26385c", band="#0e1c3d", bandink="#ffffff", star="#ffffff")),
37
+ Theme("light-space", "Moon light", False, Palette(
38
+ bg1="#c3cddf", bg2="#dfe5ef", ink="#0b1a3a", mute="#36435f", accent="#1858c4", accent2="#0b3d91",
39
+ card="#e9eef6", cardline="#b3bfd5", line="#a7b4cc", band="#0b1a3a", bandink="#ffffff", star="#0b1a3a")),
40
  )}
41
  DEFAULT_THEME = "dark-space"
42
 
backend/tests/test_slides.py CHANGED
@@ -436,3 +436,155 @@ def test_the_closing_is_tried_again_and_a_repeated_heading_goes(client, auth, ru
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  assert len(tries) == 2 and slides[-1]["closing"] == "Price the work like it matters."
437
  headings = [s["heading"] for s in slides]
438
  assert len(headings) == len(set(headings)) and 6 <= len(slides) <= 8
439
+
440
+
441
+ @pytest.mark.parametrize("key", [("title", "a"), ("title", "c"), ("section", "a"), ("section", "b")])
442
+ def test_light_planets_sit_on_their_ring(key):
443
+ """A planet beside rings is drawn on one of them, tilt included, whatever the seed."""
444
+ import math
445
+
446
+ from app.slides.decor import build
447
+
448
+ for seed in range(40):
449
+ shapes = build("light-space", *key, seed, 0).shapes
450
+ rings = [s for s in shapes if s.kind == "ring"]
451
+ for p in (s for s in shapes if s.kind == "planet"):
452
+ def off(r, p=p): # how far the planet's centre is from the ring's line, in units of the ring
453
+ rot = math.radians(-r.rot)
454
+ dx, dy = p.cx - r.cx, p.cy - r.cy
455
+ x, y = dx * math.cos(rot) - dy * math.sin(rot), dx * math.sin(rot) + dy * math.cos(rot)
456
+ return abs(math.hypot(x / r.rx, y / r.ry) - 1)
457
+ assert min(off(r) for r in rings) < 1e-6, (key, seed)
458
+
459
+
460
+ @pytest.mark.parametrize("key", [("title", "b"), ("points", "a")])
461
+ def test_dark_rings_wrap_round_their_planet(key):
462
+ """A ringed planet's far half is drawn before the planet (hidden behind it) and the near half after (in front)."""
463
+ from app.slides.decor import FAR, NEAR, build
464
+
465
+ for seed in range(10):
466
+ shapes = build("dark-space", *key, seed, 0).shapes
467
+ at = next(i for i, s in enumerate(shapes) if s.kind == "planet")
468
+ rings = [(i, s) for i, s in enumerate(shapes) if s.kind in ("ring", "band")]
469
+ assert rings and all(s.arc for _, s in rings)
470
+ assert all(i < at for i, s in rings if s.arc == FAR) and all(i > at for i, s in rings if s.arc == NEAR)
471
+ assert all(s.rx * s.inner > shapes[at].r for _, s in rings if s.kind == "band") # the band clears the planet
472
+
473
+
474
+ @pytest.mark.parametrize("theme", ["dark-space", "light-space"])
475
+ def test_every_variant_marks_its_editable_text(theme):
476
+ """Every piece of a slide's own text is drawn in a span the editor can find, with the field's real limit; numbers
477
+ and footers are not editable."""
478
+ from app.slides.content import field_limit
479
+
480
+ for fmt in ("detailed", "presenter"):
481
+ for layout, variants in GEOMETRY[theme].items():
482
+ for v in variants:
483
+ deck = clamp_deck({"title": "T", "slides": [{"layout": layout, **FULL[layout]}]}, fmt)
484
+ deck["slides"][0]["variant"] = v
485
+ html = slide_html(deck, 0, theme, fmt, 3)
486
+ found = dict(re.findall(r'data-f="([^"]+)" data-max="(\d+)"', html))
487
+ assert "heading" in found or layout in ("quote", "closing"), (layout, v)
488
+ assert all(int(n) == field_limit(layout, f, fmt) for f, n in found.items())
489
+ s = deck["slides"][0]
490
+ want = {"points": [f"points.{i}.text" for i in range(len(s["points"]))], "stat": ["stat.value", "stat.label"],
491
+ "quote": ["quote.text", "quote.by"], "two_column": ["left.label", "right.items.1"],
492
+ "closing": [f"takeaways.{i}" for i in range(len(s["takeaways"]))] + ["closing"]}.get(layout, [])
493
+ assert set(want) <= set(found), (fmt, layout, v, found)
494
+ assert not any(f.startswith("footer") for f in found)
495
+
496
+
497
+ def _ready_deck(client, auth, run_jobs, monkeypatch, llm): # noqa: F811
498
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
499
+ _fake_many(monkeypatch, pricing["path"], {})
500
+ llm["OutlineDeck"] = _outline(5)
501
+ llm["WriteClosing"] = CLOSING
502
+ art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
503
+ "document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
504
+ run_jobs()
505
+ return client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
506
+
507
+
508
+ def test_edited_deck_is_saved_fitted_and_downloaded(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
509
+ done = _ready_deck(client, auth, run_jobs, monkeypatch, llm)
510
+ slides = copy.deepcopy(done["content"]["deck"]["slides"])
511
+ assert len(done["content"]["slide_slots"]) == len(slides) and "heading" in done["content"]["slide_slots"][0]
512
+ pi = next(i for i, s in enumerate(slides) if s["points"] and s["layout"] == "points")
513
+ slides[0]["heading"] = "A brand new title"
514
+ slides[0]["notes"] = "Say hello first."
515
+ slides[pi]["points"][0]["text"] = "Edited point " + "very long " * 60
516
+ slides[-1]["takeaways"][1] = " An <b>edited</b> takeaway "
517
+ url = f"/api/artifacts/{done['id']}/deck"
518
+ saved = client.put(url, json={"slides": slides}, headers=auth)
519
+ assert saved.status_code == 200, saved.text
520
+ after = saved.json()["content"]
521
+ s = after["deck"]["slides"]
522
+ assert s[0]["heading"] == "A brand new title" and s[0]["notes"] == "Say hello first."
523
+ assert s[pi]["points"][0]["text"].startswith("Edited point") and len(s[pi]["points"][0]["text"]) <= LIMITS["detailed"]["text"]
524
+ assert s[-1]["takeaways"][1] == "An edited takeaway" # plain text only
525
+ before = done["content"]["deck"]["slides"]
526
+ assert [(x["layout"], x["variant"]) for x in s] == [(x["layout"], x["variant"]) for x in before] # designs kept
527
+ assert "A brand new title" in after["slides_html"][0]
528
+ pptx = client.get(f"/api/artifacts/{done['id']}/slides.pptx", headers=auth)
529
+ texts = [sh.text_frame.text for sh in Presentation(io.BytesIO(pptx.content)).slides[0].shapes if sh.has_text_frame]
530
+ assert any("A brand new title" in t for t in texts)
531
+
532
+ assert client.put(url, json={"slides": []}, headers=auth).status_code == 422
533
+ assert client.put(url, json={"slides": [{"layout": "points", "heading": ""}]}, headers=auth).status_code == 400
534
+ assert client.put(url, json={"slides": [slides[0]] * 31}, headers=auth).status_code == 400
535
+ kept = client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"]["deck"]["slides"]
536
+ assert kept[0]["heading"] == "A brand new title" and len(kept) == len(slides) # a refused save changes nothing
537
+
538
+
539
+ def test_slides_can_be_added_removed_moved_and_change_layout(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
540
+ done = _ready_deck(client, auth, run_jobs, monkeypatch, llm)
541
+ slides = copy.deepcopy(done["content"]["deck"]["slides"])
542
+ n = len(slides)
543
+ pi = next(i for i, s in enumerate(slides) if s["layout"] == "points")
544
+ edited = copy.deepcopy(slides)
545
+ edited[pi]["points"].append({"term": "", "text": "A point added by hand"}) # a text box added
546
+ edited[pi]["lead"] = "" # and one removed
547
+ stat = {"layout": "stat", "variant": "", "heading": "A new stat slide", "stat": {"value": "42%", "label": "Readers who stayed"}}
548
+ edited.insert(1, stat) # a slide added
549
+ edited.insert(2, copy.deepcopy(edited[0]) | {"variant": ""}) # a slide duplicated
550
+ del edited[-2] # a slide removed
551
+ edited[-1], edited[-2] = edited[-2], edited[-1] # two moved
552
+ url = f"/api/artifacts/{done['id']}"
553
+ prev = client.post(f"{url}/deck-preview", json={"slides": edited}, headers=auth)
554
+ assert prev.status_code == 200, prev.text
555
+ got = prev.json()
556
+ out = got["slides"]
557
+ assert len(out) == n + 1 and len(got["slides_html"]) == n + 1 and len(got["slide_slots"]) == n + 1
558
+ assert out[1]["layout"] == "stat" and out[1]["variant"] and "42%" in got["slides_html"][1]
559
+ assert "value" in got["slide_slots"][1] and "label" in got["slide_slots"][1]
560
+ moved = next(s for s in out if s["heading"] == slides[pi]["heading"])
561
+ assert moved["points"][-1]["text"] == "A point added by hand" and moved["lead"] == ""
562
+ pairs = [(x["layout"], x["variant"]) for x in out]
563
+ assert all(a != b for a, b in zip(pairs, pairs[1:], strict=False)) # no two alike in a row
564
+ assert client.get(url, headers=auth).json()["content"]["deck"]["slides"] == done["content"]["deck"]["slides"] # preview saves nothing
565
+
566
+ # a layout change: the points slide becomes a quote slide
567
+ q = next(i for i, s in enumerate(edited) if s["layout"] == "points")
568
+ edited[q] = {**edited[q], "layout": "quote", "variant": "", "quote": {"text": "Price the work like it matters.", "by": "A writer"}}
569
+ saved = client.put(f"{url}/deck", json={"slides": edited}, headers=auth).json()["content"]
570
+ assert saved["deck"]["slides"][q]["layout"] == "quote" and saved["deck"]["slides"][q]["variant"]
571
+ assert len(saved["deck"]["slides"]) == n + 1
572
+ pptx = client.get(f"{url}/slides.pptx", headers=auth)
573
+ assert len(Presentation(io.BytesIO(pptx.content)).slides) == n + 1
574
+
575
+
576
+ def test_fill_variants_keeps_good_variants_and_fixes_the_rest():
577
+ from app.slides.build import fill_variants
578
+
579
+ deck = clamp_deck({"title": "T", "slides": [{"layout": lay, **FULL[lay]} for lay in ("title", "points", "points", "stat", "closing")]},
580
+ "detailed")
581
+ assign_variants(deck["slides"], "dark-space", seed=3)
582
+ before = [s["variant"] for s in deck["slides"]]
583
+ fill_variants(deck["slides"], "dark-space", 3)
584
+ assert [s["variant"] for s in deck["slides"]] == before # nothing to fix: nothing changes
585
+ deck["slides"][3]["variant"] = "" # new
586
+ deck["slides"][2]["variant"] = deck["slides"][1]["variant"] # the same as the slide before it
587
+ fill_variants(deck["slides"], "dark-space", 3)
588
+ assert deck["slides"][3]["variant"] in VARIANTS["stat"]
589
+ assert deck["slides"][2]["variant"] != deck["slides"][1]["variant"]
590
+ assert [s["variant"] for s in deck["slides"]][:2] == before[:2] and deck["slides"][4]["variant"] == before[4]
frontend/src/api/endpoints.ts CHANGED
@@ -161,6 +161,31 @@ export const artifactsApi = {
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 = {
 
161
  retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
162
  /** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
163
  slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
164
+ /** A slide deck's slides as the editor has them, drawn without saving (after a slide or a text box is added,
165
+ * removed, moved or changed to another layout). Returns them as they will be kept. */
166
+ previewDeck: (id: string, slides: EditSlide[]) =>
167
+ post<{ slides: EditSlide[]; slides_html: string[]; slide_slots: string[][] }>(`/api/artifacts/${id}/deck-preview`, { slides }),
168
+ /** The editor's slides kept: the server fits the deck again and returns it. */
169
+ saveDeck: (id: string, slides: EditSlide[]) => put<Artifact>(`/api/artifacts/${id}/deck`, { slides }),
170
+ };
171
+
172
+ /** One slide in the stored shape (backend app/slides/content.py, empty_slide). */
173
+ export type EditSlide = {
174
+ layout: string;
175
+ variant: string;
176
+ kicker: string;
177
+ heading: string;
178
+ lead: string;
179
+ points: { term: string; text: string }[];
180
+ left: { label: string; items: string[] };
181
+ right: { label: string; items: string[] };
182
+ stat: { value: string; label: string };
183
+ quote: { text: string; by: string };
184
+ takeaways: string[];
185
+ closing: string;
186
+ notes: string;
187
+ sources: { title?: string; path: string; line_start: number; line_end: number }[];
188
+ shrink?: number;
189
  };
190
 
191
  export const jobsApi = {
frontend/src/components/SlideDeck.tsx CHANGED
@@ -1,6 +1,7 @@
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";
@@ -11,8 +12,8 @@ import { ConfirmDeleteModal, errorMessage, formatDate, JobProgress, Modal } from
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 = [
@@ -108,16 +109,203 @@ export function SlideDeckDialog({ notebookId, notebookTitle, chats, currentChatI
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;
@@ -132,9 +320,10 @@ export function SlideFrame({ html, title, fit = "width", hidden = false }: {
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
  }
@@ -279,8 +468,9 @@ function FloatingMenu({ menu, children }: { menu: ReturnType<typeof useMenu>; ch
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";
@@ -288,6 +478,7 @@ function useDeckDownload(artifact: Artifact) {
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");
@@ -322,9 +513,9 @@ function ago(iso: string | null): string {
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}
@@ -349,8 +540,9 @@ function DownloadMenu({ artifact }: { artifact: Artifact }) {
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>;
@@ -358,7 +550,7 @@ function DeckMenu({ artifact, onRename, onPresent, onDelete, notes, onNotes }: {
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);
@@ -414,21 +606,207 @@ const PencilIcon = () => (
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(() => {
@@ -446,22 +824,35 @@ export function SlideDeckViewer({ artifact, onClose, onDelete }: {
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>
@@ -470,29 +861,55 @@ export function SlideDeckViewer({ artifact, onClose, onDelete }: {
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("; ")}
@@ -500,6 +917,10 @@ export function SlideDeckViewer({ artifact, onClose, onDelete }: {
500
  )}
501
  </div>
502
  )}
 
 
 
 
503
  </li>
504
  );
505
  })}
@@ -507,6 +928,30 @@ export function SlideDeckViewer({ artifact, onClose, onDelete }: {
507
  )}
508
  </div>
509
  {presenting !== null && <SlidePresenter artifact={artifact} start={presenting} onClose={() => setPresenting(null)} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  </Modal>
511
  );
512
  }
@@ -598,7 +1043,7 @@ export function SlideDeckRow({ artifact: initial, onRemoved }: { artifact: Artif
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
  );
 
1
  import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
  import { createPortal } from "react-dom";
3
+ import { artifactsApi, type EditSlide, type GenerateBody } from "../api/endpoints";
4
+ import { addActions, convert, type DeckFmt, LAYOUTS, MAX_SLIDES, newSlide, problem, removable, removeField, setText } from "./slideDeckEdit";
5
  import type { Artifact, ChatSummary } from "../api/types";
6
  import { Dropdown } from "./Dropdown";
7
  import { useJob } from "../hooks/useJob";
 
12
  /** The two deck themes. Their slides are laid out on the server (backend/app/slides); the thumbnails are rendered from
13
  * them (backend/scripts/render_slide_thumbnails.py). */
14
  export const SLIDE_THEMES = [
15
+ { id: "dark-space", name: "Night stellar", blurb: "Night sky, planets and a blue glow" },
16
+ { id: "light-space", name: "Moon light", blurb: "Moonlit sky, orbits and constellations" },
17
  ] as const;
18
 
19
  const FORMATS = [
 
109
  );
110
  }
111
 
112
+ /** The editor that runs inside an editable slide's frame. The frame is sandboxed with allow-scripts but not
113
+ * allow-same-origin, so this script (and anything else in the slide) can never reach this page: the two only talk by
114
+ * postMessage. The page says when edit mode is on and which text boxes may be removed ({type: "sd-mode", editing,
115
+ * removable}); the frame reports each edit ({type: "sd-edit", field, text}), a box's remove button
116
+ * ({type: "sd-remove", field}) and that it is ready ({type: "sd-ready"}). Self-contained: it is sent as source.
117
+ *
118
+ * In edit mode the spans the server marks with data-f (and data-max) are editable, single line, plain text, no longer
119
+ * than the field holds; every text box is framed (dashed, solid on hover, lit while typing), and a click anywhere in a
120
+ * box types into it. Sizes are slide pixels: the slide is drawn 1920 wide and scaled down. */
121
+ function slideEditor() {
122
+ const css = `.sd-box{outline:3px dashed rgba(33,124,255,.8);outline-offset:10px;border-radius:6px;cursor:text;
123
+ background-color:rgba(33,124,255,.07);transition:outline-color .12s,box-shadow .12s}
124
+ .sd-box:hover{outline-style:solid}
125
+ .sd-box:focus-within{outline:4px solid #217cff;box-shadow:0 0 0 14px rgba(33,124,255,.22)}
126
+ [data-f]{outline:none;caret-color:#217cff}
127
+ [data-f]:empty{display:inline-block;min-width:1.2em;min-height:1em;background:rgba(255,93,93,.25)}
128
+ .sd-x{position:absolute;top:-34px;right:-34px;z-index:2;width:48px;height:48px;padding:0;border:3px solid #fff;border-radius:50%;
129
+ background:#e5484d;color:#fff;font:700 30px/1 Inter,system-ui,sans-serif;cursor:pointer;opacity:.45;transition:opacity .12s;
130
+ box-shadow:0 4px 14px rgba(0,0,0,.35)}
131
+ .sd-box:hover .sd-x,.sd-box:focus-within .sd-x{opacity:1}
132
+ .sd-x:hover{background:#c9353a}`;
133
+ const spans = Array.from(document.querySelectorAll<HTMLElement>("[data-f]"));
134
+ const boxes = Array.from(new Set(spans.map((el) => el.closest<HTMLElement>(".e")).filter((b): b is HTMLElement => !!b)));
135
+ const style = document.createElement("style");
136
+ style.textContent = css;
137
+ let on = false;
138
+ const field = (t: EventTarget | null) => {
139
+ const el = t as HTMLElement | null;
140
+ return on && el && el.dataset && el.dataset.f ? el : null;
141
+ };
142
+ const room = (el: HTMLElement) => {
143
+ const sel = document.getSelection();
144
+ const picked = sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.toString().length : 0;
145
+ return Number(el.dataset.max || 9999) - ((el.textContent || "").length - picked);
146
+ };
147
+ let removers: HTMLElement[] = [];
148
+ const setMode = (editing: boolean, removable: string[]) => {
149
+ on = editing;
150
+ removers.forEach((x) => x.remove());
151
+ removers = [];
152
+ for (const el of spans) {
153
+ if (!editing) {
154
+ el.removeAttribute("contenteditable");
155
+ continue;
156
+ }
157
+ try {
158
+ el.contentEditable = "plaintext-only";
159
+ } catch {
160
+ el.contentEditable = "true"; // no plaintext-only: paste is made plain below
161
+ }
162
+ el.spellcheck = true;
163
+ }
164
+ boxes.forEach((b) => b.classList.toggle("sd-box", editing));
165
+ // One remove button per box that may go: for a point's box, the point itself (its text) before its title.
166
+ if (editing) {
167
+ for (const b of boxes) {
168
+ const fields = Array.from(b.querySelectorAll<HTMLElement>("[data-f]")).map((el) => el.dataset.f || "");
169
+ const f = fields.find((x) => x.endsWith(".text") && removable.includes(x)) || fields.find((x) => removable.includes(x));
170
+ if (!f) continue;
171
+ const x = document.createElement("button");
172
+ x.className = "sd-x";
173
+ x.type = "button";
174
+ x.textContent = "×";
175
+ x.title = "Remove this text box";
176
+ x.setAttribute("aria-label", "Remove this text box");
177
+ x.contentEditable = "false";
178
+ x.dataset.remove = f;
179
+ b.appendChild(x);
180
+ removers.push(x);
181
+ }
182
+ }
183
+ if (editing) document.head.appendChild(style);
184
+ else {
185
+ style.remove();
186
+ const sel = document.getSelection();
187
+ if (sel) sel.removeAllRanges();
188
+ }
189
+ };
190
+ // A click in a box but not on its text (the box is often larger than the words): type at the end of the nearest text.
191
+ document.addEventListener("mousedown", (e) => {
192
+ const t = e.target as HTMLElement | null;
193
+ if (!on || !t || field(t) || !t.closest) return;
194
+ const x = t.closest<HTMLElement>(".sd-x");
195
+ if (x) {
196
+ e.preventDefault();
197
+ parent.postMessage({ type: "sd-remove", field: x.dataset.remove }, "*");
198
+ return;
199
+ }
200
+ const box = t.closest<HTMLElement>(".sd-box");
201
+ if (!box) return;
202
+ let near: HTMLElement | null = null;
203
+ let best = Infinity;
204
+ box.querySelectorAll<HTMLElement>("[data-f]").forEach((el) => {
205
+ const r = el.getBoundingClientRect();
206
+ const d = Math.hypot(Math.max(r.left - e.clientX, 0, e.clientX - r.right), Math.max(r.top - e.clientY, 0, e.clientY - r.bottom));
207
+ if (d < best) [near, best] = [el, d];
208
+ });
209
+ if (!near) return;
210
+ e.preventDefault();
211
+ const el: HTMLElement = near;
212
+ el.focus();
213
+ const range = document.createRange();
214
+ range.selectNodeContents(el);
215
+ range.collapse(false);
216
+ const sel = document.getSelection();
217
+ if (sel) {
218
+ sel.removeAllRanges();
219
+ sel.addRange(range);
220
+ }
221
+ });
222
+ document.addEventListener("beforeinput", (e) => {
223
+ const el = field(e.target);
224
+ if (!el) return;
225
+ if (e.inputType === "insertParagraph" || e.inputType === "insertLineBreak") {
226
+ e.preventDefault(); // one line: Enter is done
227
+ el.blur();
228
+ } else if (e.inputType === "insertText" && e.data && e.data.length > room(el)) {
229
+ e.preventDefault();
230
+ }
231
+ });
232
+ document.addEventListener("paste", (e) => {
233
+ const el = field(e.target);
234
+ if (!el) return;
235
+ e.preventDefault();
236
+ const text = ((e.clipboardData && e.clipboardData.getData("text/plain")) || "").replace(/\s+/g, " ").slice(0, Math.max(room(el), 0));
237
+ if (text) document.execCommand("insertText", false, text);
238
+ });
239
+ document.addEventListener("drop", (e) => {
240
+ if (field(e.target)) e.preventDefault(); // dragged text could bring markup or overflow
241
+ });
242
+ document.addEventListener("input", (e) => {
243
+ const el = field(e.target);
244
+ if (el) parent.postMessage({ type: "sd-edit", field: el.dataset.f, text: el.textContent || "" }, "*");
245
+ });
246
+ document.addEventListener("keydown", (e) => {
247
+ const el = field(e.target);
248
+ if (el && e.key === "Escape") el.blur();
249
+ });
250
+ window.addEventListener("message", (e) => {
251
+ if (e.source === parent && e.data && e.data.type === "sd-mode") {
252
+ setMode(!!e.data.editing, Array.isArray(e.data.removable) ? e.data.removable : []);
253
+ }
254
+ });
255
+ parent.postMessage({ type: "sd-ready" }, "*");
256
+ }
257
+
258
+ const EDITOR_TAG = `<script>(${slideEditor.toString()})()</script>`;
259
+
260
+ /** One slide (HTML from our own layout, every word escaped) in a sandboxed frame, scaled to fit the box it is given.
261
+ * With `onEdit` the frame also runs the slide editor (still isolated from this page: an opaque origin), and while
262
+ * `editing` its text is edited in place. */
263
+ export function SlideFrame({ html, title, fit = "width", hidden = false, editing = false, onEdit, removable, onRemove }: {
264
  html: string;
265
  title: string;
266
  /** "width": as wide as the box, 16:9. "contain": as large as fits in the box both ways (presenting). */
267
  fit?: "width" | "contain";
268
  hidden?: boolean;
269
+ editing?: boolean;
270
+ /** Text was edited: where it lives on the slide ("heading", "points.2.text") and what it says now. */
271
+ onEdit?: (field: string, text: string) => void;
272
+ /** The text boxes that get a remove button in edit mode, and what a click on one does. */
273
+ removable?: string[];
274
+ onRemove?: (field: string) => void;
275
  }) {
276
  const box = useRef<HTMLDivElement>(null);
277
+ const frame = useRef<HTMLIFrameElement>(null);
278
  const [scale, setScale] = useState(0.2);
279
+ const editRef = useRef(onEdit);
280
+ editRef.current = onEdit;
281
+ const editingRef = useRef(editing);
282
+ editingRef.current = editing;
283
+ const removeRef = useRef(onRemove);
284
+ removeRef.current = onRemove;
285
+ const removableKey = (removable ?? []).join(",");
286
+ const removableRef = useRef(removable ?? []);
287
+ removableRef.current = removable ?? [];
288
+ const withEditor = !!onEdit;
289
+ const doc = withEditor ? html.replace("</body>", `${EDITOR_TAG}</body>`) : html;
290
+ const tell = (on: boolean) =>
291
+ frame.current?.contentWindow?.postMessage({ type: "sd-mode", editing: on, removable: removableRef.current }, "*");
292
+ useEffect(() => {
293
+ if (!withEditor) return;
294
+ const onMessage = (e: MessageEvent) => {
295
+ if (e.source !== frame.current?.contentWindow || !e.data || typeof e.data !== "object") return;
296
+ if (e.data.type === "sd-ready") tell(editingRef.current); // loaded (again): in the mode the page is in
297
+ else if (e.data.type === "sd-edit" && typeof e.data.field === "string" && typeof e.data.text === "string") {
298
+ if (editingRef.current) editRef.current?.(e.data.field, e.data.text);
299
+ } else if (e.data.type === "sd-remove" && typeof e.data.field === "string" && removableRef.current.includes(e.data.field)) {
300
+ if (editingRef.current) removeRef.current?.(e.data.field);
301
+ }
302
+ };
303
+ window.addEventListener("message", onMessage);
304
+ return () => window.removeEventListener("message", onMessage);
305
+ }, [withEditor]);
306
+ useEffect(() => {
307
+ if (withEditor) tell(editing);
308
+ }, [editing, withEditor, removableKey]);
309
  useEffect(() => {
310
  const el = box.current;
311
  if (!el) return;
 
320
  return () => ro.disconnect();
321
  }, [fit]);
322
  return (
323
+ <div ref={box} className={`sd-frame sd-frame-${fit}${editing ? " sd-frame-edit" : ""}`}
324
+ style={hidden ? { visibility: "hidden" } : undefined} aria-hidden={hidden}>
325
+ <iframe ref={frame} title={title} srcDoc={doc} sandbox={withEditor ? "allow-scripts" : ""} tabIndex={editing ? undefined : -1}
326
+ width={SLIDE_W} height={SLIDE_H} style={{ transform: `translate(-50%, -50%) scale(${scale})` }} />
327
  </div>
328
  );
329
  }
 
468
  );
469
  }
470
 
471
+ /** Saves the deck as a PDF (exactly as shown) or an editable PowerPoint, drawn by the server. `prepare` runs first
472
+ * (the viewer saves unsaved edits, so the file has them) and stops the download when it returns false. */
473
+ function useDeckDownload(artifact: Artifact, prepare?: () => Promise<boolean>) {
474
  const [busy, setBusy] = useState<"pdf" | "pptx" | null>(null);
475
  const [error, setError] = useState<string | null>(null);
476
  const name = deckTitle(artifact).replace(/[^\w\- ]+/g, "").trim().slice(0, 80) || "Slide deck";
 
478
  setBusy(ext);
479
  setError(null);
480
  try {
481
+ if (prepare && !(await prepare())) return;
482
  const blob = await artifactsApi.slidesFile(artifact.id, ext);
483
  const url = URL.createObjectURL(blob);
484
  const a = document.createElement("a");
 
513
  }
514
 
515
  /** Download, in the popup's header: a menu of PDF or PowerPoint. */
516
+ function DownloadMenu({ artifact, prepare }: { artifact: Artifact; prepare?: () => Promise<boolean> }) {
517
  const menu = useMenu();
518
+ const dl = useDeckDownload(artifact, prepare);
519
  return (
520
  <div className="sd-menu-wrap" ref={menu.ref}>
521
  <button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} disabled={dl.busy !== null}
 
540
 
541
  /** Everything a deck can do, behind its three dots (the row's and the popup's): rename, download, present, speaker
542
  * notes (in the popup) and delete (asks once more first). */
543
+ function DeckMenu({ artifact, onRename, onPresent, onDelete, notes, onNotes, prepare }: {
544
  artifact: Artifact;
545
+ prepare?: () => Promise<boolean>;
546
  onRename?: () => void;
547
  onPresent: () => void;
548
  onDelete: () => Promise<void>;
 
550
  onNotes?: () => void;
551
  }) {
552
  const menu = useMenu();
553
+ const dl = useDeckDownload(artifact, prepare);
554
  const [confirming, setConfirming] = useState(false);
555
  const pick = (fn: () => void) => () => {
556
  menu.setOpen(false);
 
606
  </SvgIcon>
607
  );
608
 
609
+ const ArrowUpIcon = () => <SvgIcon><path d="M12 19V5M6 11l6-6 6 6" /></SvgIcon>;
610
+ const ArrowDownIcon = () => <SvgIcon><path d="M12 5v14M6 13l6 6 6-6" /></SvgIcon>;
611
+ const DuplicateIcon = () => (
612
+ <SvgIcon>
613
+ <rect x="8" y="8" width="12" height="12" rx="2" />
614
+ <path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
615
+ </SvgIcon>
616
+ );
617
+
618
+ /** The working copy edit mode changes: the slides as the editor has them, drawn, and what each slide's design has a
619
+ * place for. Typing changes the slides only (the frame already shows it); anything else is drawn again by the server. */
620
+ type Draft = { slides: EditSlide[]; pages: string[]; slots: string[][] };
621
+
622
+ function draftOf(artifact: Artifact): Draft {
623
+ const c = artifact.content;
624
+ return {
625
+ slides: JSON.parse(JSON.stringify(Array.isArray(c.deck?.slides) ? c.deck.slides : [])) as EditSlide[],
626
+ pages: Array.isArray(c.slides_html) ? (c.slides_html as string[]) : [],
627
+ slots: Array.isArray(c.slide_slots) ? (c.slide_slots as string[][]) : [],
628
+ };
629
+ }
630
+
631
+ /** One slide's controls in edit mode: its layout, the text boxes it can gain, and moving, duplicating or deleting it. */
632
+ function SlideTools({ k, count, slide, slots, fmt, busy, onChange, onMove, onDuplicate, onDelete }: {
633
+ k: number;
634
+ count: number;
635
+ slide: EditSlide;
636
+ slots: string[];
637
+ fmt: DeckFmt;
638
+ busy: boolean;
639
+ onChange: (next: EditSlide) => void;
640
+ onMove: (by: -1 | 1) => void;
641
+ onDuplicate: () => void;
642
+ onDelete: () => void;
643
+ }) {
644
+ return (
645
+ <div className="sd-tools" role="toolbar" aria-label={`Slide ${k + 1}`}>
646
+ <span className="sd-tools-no">Slide {k + 1}</span>
647
+ <label className="sd-tools-layout">
648
+ <span className="sr-only">Layout of slide {k + 1}</span>
649
+ <select className="input" value={slide.layout} disabled={busy} onChange={(e) => onChange(convert(slide, e.target.value, fmt))}>
650
+ {LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
651
+ </select>
652
+ </label>
653
+ {addActions(slide, slots, fmt).map((a) => (
654
+ <button key={a.id} type="button" className="sd-tool" disabled={busy} onClick={() => onChange(a.apply(slide))}>+ {a.label}</button>
655
+ ))}
656
+ <span className="sd-tools-gap" />
657
+ <button type="button" className="sd-icon-btn sd-tool-icon" title="Move up" aria-label={`Move slide ${k + 1} up`}
658
+ disabled={busy || k === 0} onClick={() => onMove(-1)}><ArrowUpIcon /></button>
659
+ <button type="button" className="sd-icon-btn sd-tool-icon" title="Move down" aria-label={`Move slide ${k + 1} down`}
660
+ disabled={busy || k === count - 1} onClick={() => onMove(1)}><ArrowDownIcon /></button>
661
+ <button type="button" className="sd-icon-btn sd-tool-icon" title="Duplicate slide" aria-label={`Duplicate slide ${k + 1}`}
662
+ disabled={busy || count >= MAX_SLIDES} onClick={onDuplicate}><DuplicateIcon /></button>
663
+ <button type="button" className="sd-icon-btn sd-tool-icon sd-tool-danger" title="Delete slide" aria-label={`Delete slide ${k + 1}`}
664
+ disabled={busy || count <= 1} onClick={onDelete}><TrashIcon size={20} /></button>
665
+ </div>
666
+ );
667
+ }
668
+
669
+ /** "+ Add slide" after a slide in edit mode: a new slide of the layout picked. */
670
+ function AddSlide({ after, busy, full, onAdd }: { after: number; busy: boolean; full: boolean; onAdd: (layout: string) => void }) {
671
+ return (
672
+ <div className="sd-add-slide">
673
+ <label>
674
+ <span className="sr-only">Add a slide after slide {after + 1}</span>
675
+ <select className="input" value="" disabled={busy || full} onChange={(e) => e.target.value && onAdd(e.target.value)}>
676
+ <option value="">{full ? `At most ${MAX_SLIDES} slides` : "+ Add slide here"}</option>
677
+ {LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
678
+ </select>
679
+ </label>
680
+ </div>
681
+ );
682
+ }
683
+
684
+ /** The deck in a popup: its actions along the top (edit, present, a larger preview, download, more) and every slide
685
+ * below, one after another. A click on a slide presents from it. The pencil opens edit mode: every text on the slides
686
+ * is a framed text box to type in (with a remove button where the slide can do without it), each slide has its
687
+ * controls (layout, text boxes to add, move, duplicate, delete), slides can be added between them, and the speaker
688
+ * notes are editable too. Save keeps it all; leaving edit mode or closing with unsaved changes asks first. Presenting
689
+ * or downloading saves first, so both show the edited deck. */
690
+ export function SlideDeckViewer({ artifact, onClose, onDelete, onSaved }: {
691
  artifact: Artifact;
692
  onClose: () => void;
693
  onDelete: () => Promise<void>;
694
+ /** The deck as the server has it now (saved, or fetched afresh). */
695
+ onSaved: (artifact: Artifact) => void;
696
  }) {
697
+ const stored = deckOf(artifact);
698
  const [big, setBig] = useState(false);
699
  const [notes, setNotes] = useState(false);
700
  const [presenting, setPresenting] = useState<number | null>(null);
701
  const [inView, setInView] = useState(0);
702
+ const [draft, setDraft] = useState<Draft | null>(null); // edit mode when set
703
+ const [dirty, setDirty] = useState(false);
704
+ const [busy, setBusy] = useState(false); // the server is drawing the slides again
705
+ const [saving, setSaving] = useState(false);
706
+ const [saveError, setSaveError] = useState<string | null>(null);
707
+ // Unsaved changes and the viewer is closing, or edit mode is being left: Save, Don't save or Cancel.
708
+ const [asking, setAsking] = useState<"close" | "done" | null>(null);
709
+ const [opening, setOpening] = useState(false); // fetching the deck afresh before edit mode
710
+ const [version, setVersion] = useState(0); // bumped to draw the slides again from the stored deck (changes dropped)
711
+ const previewSeq = useRef(0);
712
  const stack = useRef<HTMLOListElement>(null);
713
  const c = artifact.content;
714
+ const fmt: DeckFmt = c.format === "presenter" ? "presenter" : "detailed";
715
+ const theme = SLIDE_THEMES.find((t) => t.id === c.theme)?.name ?? "Night stellar";
716
+ const editing = draft !== null;
717
+ const pages = draft ? draft.pages : stored.pages;
718
+ const slides: DeckSlide[] = draft ? draft.slides : stored.slides;
719
+
720
+ const save = async (): Promise<boolean> => {
721
+ if (!draft || !dirty) return true;
722
+ const wrong = problem(draft.slides);
723
+ if (wrong) {
724
+ setSaveError(wrong);
725
+ return false;
726
+ }
727
+ setSaving(true);
728
+ setSaveError(null);
729
+ try {
730
+ const next = await artifactsApi.saveDeck(artifact.id, draft.slides);
731
+ onSaved(next);
732
+ setDraft(draftOf(next));
733
+ setDirty(false);
734
+ return true;
735
+ } catch (e) {
736
+ setSaveError(errorMessage(e));
737
+ return false;
738
+ } finally {
739
+ setSaving(false);
740
+ }
741
+ };
742
+ // A change to the deck's structure (a slide or a text box added, removed, moved, another layout): drawn by the server.
743
+ const restructure = async (next: EditSlide[]) => {
744
+ if (!draft) return;
745
+ setDraft({ ...draft, slides: next });
746
+ setDirty(true);
747
+ setBusy(true);
748
+ setSaveError(null);
749
+ const seq = ++previewSeq.current;
750
+ try {
751
+ const got = await artifactsApi.previewDeck(artifact.id, next);
752
+ if (seq === previewSeq.current) setDraft({ slides: got.slides, pages: got.slides_html, slots: got.slide_slots });
753
+ } catch (e) {
754
+ if (seq === previewSeq.current) setSaveError(errorMessage(e));
755
+ } finally {
756
+ if (seq === previewSeq.current) setBusy(false);
757
+ }
758
+ };
759
+ const change = (k: number, slide: EditSlide) => draft && void restructure(draft.slides.map((x, i) => (i === k ? slide : x)));
760
+ const move = (k: number, by: -1 | 1) => {
761
+ if (!draft) return;
762
+ const next = [...draft.slides];
763
+ [next[k], next[k + by]] = [next[k + by], next[k]];
764
+ void restructure(next);
765
+ };
766
+ const insert = (at: number, slide: EditSlide) => draft && void restructure([...draft.slides.slice(0, at), slide, ...draft.slides.slice(at)]);
767
+ const typed = (k: number, field: string, text: string) => {
768
+ setDraft((d) => (d ? { ...d, slides: d.slides.map((x, i) => (i === k ? setText(x, field, text) : x)) } : d));
769
+ setDirty(true);
770
+ };
771
+ const requestClose = () => (dirty ? setAsking("close") : onClose());
772
+ const leave = () => {
773
+ setDraft(null);
774
+ setDirty(false);
775
+ setBusy(false);
776
+ setSaveError(null);
777
+ previewSeq.current++;
778
+ setVersion((v) => v + 1);
779
+ };
780
+ const stopEditing = () => (dirty ? setAsking("done") : leave());
781
+ // Edit mode works on the deck as the server has it now (a deck loaded before an update has no editable text).
782
+ const startEditing = async () => {
783
+ setOpening(true);
784
+ setSaveError(null);
785
+ try {
786
+ const fresh = await artifactsApi.get(artifact.id);
787
+ onSaved(fresh);
788
+ if (!deckOf(fresh).pages.some((html) => html.includes("data-f="))) {
789
+ setSaveError("This deck can't be edited. Generate it again to edit its text.");
790
+ return;
791
+ }
792
+ setDraft(draftOf(fresh));
793
+ } catch (e) {
794
+ setSaveError(errorMessage(e));
795
+ } finally {
796
+ setOpening(false);
797
+ }
798
+ };
799
+ const present = async (k: number) => {
800
+ if (await save()) setPresenting(k);
801
+ };
802
+
803
+ // Leaving the page (closing the tab, reloading) with unsaved changes: the browser asks too.
804
+ useEffect(() => {
805
+ if (!dirty) return;
806
+ const warn = (e: BeforeUnloadEvent) => e.preventDefault();
807
+ window.addEventListener("beforeunload", warn);
808
+ return () => window.removeEventListener("beforeunload", warn);
809
+ }, [dirty]);
810
 
811
  // The slide most in view: Present starts there.
812
  useEffect(() => {
 
824
  }, { root, threshold: [0, 0.25, 0.5, 0.75, 1] });
825
  root.querySelectorAll("[data-i]").forEach((el) => io.observe(el));
826
  return () => io.disconnect();
827
+ }, [pages.length, notes, editing]);
828
 
829
  const actions = (
830
  <div className="sd-head-actions" role="toolbar" aria-label="Slide deck">
831
+ {editing ? (
832
+ <>
833
+ <button type="button" className="btn btn-primary btn-small sd-save" disabled={saving || busy || !dirty} onClick={() => void save()}>
834
+ {saving ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Saving...</>) : "Save"}
835
+ </button>
836
+ <button type="button" className="btn btn-small sd-save" disabled={saving} onClick={stopEditing}>Done</button>
837
+ </>
838
+ ) : (
839
+ <button type="button" className="sd-icon-btn" title="Edit slides" aria-label="Edit slides" disabled={!pages.length || opening}
840
+ onClick={() => void startEditing()}>
841
+ {opening ? <span className="nbv-send-spinner" aria-hidden="true" /> : <PencilIcon />}
842
+ </button>
843
+ )}
844
+ <button type="button" className="sd-icon-btn" title="Start slideshow" aria-label="Start slideshow"
845
+ disabled={!pages.length || saving || busy} onClick={() => void present(inView)}>
846
  <PlayIcon size={20} />
847
  </button>
848
  <button type="button" className="sd-icon-btn" title={big ? "Smaller preview" : "Larger preview"} aria-pressed={big}
849
  aria-label={big ? "Smaller preview" : "Larger preview"} onClick={() => setBig((b) => !b)}>
850
  {big ? <CollapseIcon size={20} /> : <ExpandIcon size={20} />}
851
  </button>
852
+ {pages.length > 0 && <DownloadMenu artifact={artifact} prepare={save} />}
853
+ <DeckMenu artifact={artifact} onPresent={() => void present(inView)} onDelete={onDelete} prepare={save}
854
  notes={notes} onNotes={() => setNotes((n) => !n)} />
855
+ <button type="button" className="sd-icon-btn" onClick={requestClose} title="Close" aria-label="Close">
856
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
857
  <path d="M6 6l12 12M18 6L6 18" />
858
  </svg>
 
861
  );
862
 
863
  return (
864
+ <Modal title={deckTitle(artifact)} onClose={requestClose} wide actions={actions}>
865
  <div className={`sd-view${big ? " big" : ""}`}>
866
  <p className="muted small sd-meta">
867
+ {pages.length} slides · {fmt === "presenter" ? "Presenter" : "Detailed"} · {theme}
868
  {c.source ? ` · from ${sourceSummary(c.source)}` : ""}
869
+ <span className="sd-edit-hint">
870
+ {editing ? ` · Editing: click a text box to type${busy ? " · Updating..." : dirty ? " · Unsaved changes" : ""}` : ""}
871
+ </span>
872
  </p>
873
+ {saveError && !asking && <p className="error-text small sd-edit-error" role="alert">{saveError}</p>}
874
  {pages.length === 0 ? (
875
  <p className="error-text">This deck has no slides to show. Try generating it again.</p>
876
  ) : (
877
+ <ol className={`sd-stack${busy ? " sd-busy" : ""}`} ref={stack} aria-label="Slides" aria-busy={busy}>
878
  {pages.map((html, k) => {
879
  const s = slides[k];
880
  const sources = [...new Map((s?.sources ?? []).map((x) => [x.title || x.path, x])).values()];
881
+ const e = draft?.slides[k];
882
  return (
883
  <li key={k} data-i={k}>
884
+ {draft && e && (
885
+ <SlideTools k={k} count={draft.slides.length} slide={e} slots={draft.slots[k] ?? []} fmt={fmt} busy={busy || saving}
886
+ onChange={(next) => change(k, next)} onMove={(by) => move(k, by)}
887
+ onDuplicate={() => insert(k + 1, { ...JSON.parse(JSON.stringify(e)), variant: "" })}
888
+ onDelete={() => void restructure(draft.slides.filter((_, i) => i !== k))} />
889
+ )}
890
+ <div className={`sd-slide${editing ? " sd-slide-editing" : ""}`} role={editing ? undefined : "button"}
891
+ tabIndex={editing ? undefined : 0} aria-label={editing ? undefined : `Present from slide ${k + 1}: ${s?.heading ?? ""}`}
892
+ onClick={editing ? undefined : () => void present(k)}
893
+ onKeyDown={editing ? undefined : (ev) => (ev.key === "Enter" || ev.key === " ") && (ev.preventDefault(), void present(k))}>
894
+ <SlideFrame key={version} html={html} title={`Slide ${k + 1}: ${s?.heading ?? ""}`} editing={editing && !busy}
895
+ onEdit={(field, text) => typed(k, field, text)}
896
+ removable={e ? removable(e) : []} onRemove={(field) => e && change(k, removeField(e, field))} />
897
+ </div>
898
  {notes && (
899
  <div className="sd-notes">
900
+ {e ? (
901
+ <>
902
+ <label className="vw-label" htmlFor={`sd-notes-${artifact.id}-${k}`}>Slide {k + 1} notes</label>
903
+ <textarea id={`sd-notes-${artifact.id}-${k}`} className="textarea sd-notes-edit" rows={3} maxLength={1500}
904
+ placeholder="No notes for this slide yet." value={e.notes}
905
+ onChange={(ev) => typed(k, "notes", ev.target.value)} />
906
+ </>
907
+ ) : (
908
+ <>
909
+ <span className="vw-label">Slide {k + 1}</span>
910
+ <p>{s?.notes || <span className="muted">No notes for this slide.</span>}</p>
911
+ </>
912
+ )}
913
  {sources.length > 0 && (
914
  <p className="muted small">
915
  From {sources.map((x) => `${x.title || x.path} (lines ${x.line_start} to ${x.line_end})`).join("; ")}
 
917
  )}
918
  </div>
919
  )}
920
+ {draft && (
921
+ <AddSlide after={k} busy={busy || saving} full={draft.slides.length >= MAX_SLIDES}
922
+ onAdd={(layout) => insert(k + 1, newSlide(layout, fmt))} />
923
+ )}
924
  </li>
925
  );
926
  })}
 
928
  )}
929
  </div>
930
  {presenting !== null && <SlidePresenter artifact={artifact} start={presenting} onClose={() => setPresenting(null)} />}
931
+ {asking && (
932
+ <Modal title="Save changes?" onClose={() => !saving && setAsking(null)}>
933
+ <div className="stack sd-confirm">
934
+ <p>You edited this slide deck. Do you want to save your changes{asking === "close" ? " before closing" : ""}?</p>
935
+ {saveError && <p className="error-text" role="alert">{saveError}</p>}
936
+ <div className="row sd-confirm-actions">
937
+ <button type="button" className="btn" onClick={() => setAsking(null)} disabled={saving}>Cancel</button>
938
+ <button type="button" className="btn" disabled={saving} onClick={() => {
939
+ if (asking === "close") return onClose();
940
+ leave();
941
+ setAsking(null);
942
+ }}>Don't save</button>
943
+ <button type="button" className="btn btn-primary" disabled={saving || busy} autoFocus onClick={async () => {
944
+ if (!(await save())) return;
945
+ if (asking === "close") return onClose();
946
+ setAsking(null);
947
+ leave();
948
+ }}>
949
+ {saving ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Saving...</>) : "Save"}
950
+ </button>
951
+ </div>
952
+ </div>
953
+ </Modal>
954
+ )}
955
  </Modal>
956
  );
957
  }
 
1043
  </div>
1044
  )}
1045
  {error && <p className="error-text small sd-item-progress">{error}</p>}
1046
+ {open && <SlideDeckViewer artifact={artifact} onClose={() => setOpen(false)} onDelete={remove} onSaved={setArtifact} />}
1047
  {presenting && <SlidePresenter artifact={artifact} onClose={() => setPresenting(false)} />}
1048
  </div>
1049
  );
frontend/src/components/slideDeckEdit.ts ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** The slide deck editor's rules: what a slide can gain or lose, how it changes to another layout, and what must not be
2
+ * left empty. They mirror the server (backend/app/slides/content.py: LIMITS and clamp_slide), which coerces whatever
3
+ * comes back anyway; these only keep the editor from offering what the server would undo. */
4
+ import type { EditSlide } from "../api/endpoints";
5
+
6
+ export type DeckFmt = "detailed" | "presenter";
7
+
8
+ /** The layouts a slide can have, in the order the editor lists them. */
9
+ export const LAYOUTS: { id: string; name: string }[] = [
10
+ { id: "title", name: "Title" },
11
+ { id: "section", name: "Section" },
12
+ { id: "points", name: "Points" },
13
+ { id: "two_column", name: "Two columns" },
14
+ { id: "stat", name: "Big number" },
15
+ { id: "quote", name: "Quote" },
16
+ { id: "agenda", name: "Agenda" },
17
+ { id: "closing", name: "Closing" },
18
+ ];
19
+
20
+ export const MAX_SLIDES = 30;
21
+
22
+ const max = (fmt: DeckFmt) => ({ points: fmt === "presenter" ? 4 : 5, agenda: 14, takeaways: 3, items: 4 });
23
+
24
+ const clone = (s: EditSlide): EditSlide => JSON.parse(JSON.stringify(s)) as EditSlide;
25
+
26
+ /** A new slide of `layout`, with placeholder text to type over. */
27
+ export function newSlide(layout: string, fmt: DeckFmt): EditSlide {
28
+ const blank: EditSlide = {
29
+ layout: "section", variant: "", kicker: "", heading: "New slide", lead: "", points: [],
30
+ left: { label: "", items: [] }, right: { label: "", items: [] }, stat: { value: "", label: "" },
31
+ quote: { text: "", by: "" }, takeaways: [], closing: "", notes: "", sources: [],
32
+ };
33
+ return convert(blank, layout, fmt);
34
+ }
35
+
36
+ /** The slide changed to another layout. Its words carry over (points become a column's items, a closing's
37
+ * takeaways...); what the new layout needs and the slide lacks is filled with placeholder text. The old layout's
38
+ * content stays in the slide, so changing back brings it back. */
39
+ export function convert(s: EditSlide, layout: string, fmt: DeckFmt): EditSlide {
40
+ const out = { ...clone(s), layout, variant: "" };
41
+ const m = max(fmt);
42
+ const words = [...s.points.map((p) => p.text), ...s.takeaways, ...s.left.items, ...s.right.items].filter(Boolean);
43
+ const some = (n: number, fallback: string[]) => (words.length >= 2 ? words : fallback).slice(0, n);
44
+ if (layout === "points" && out.points.length < 2) {
45
+ out.points = some(m.points, ["First point", "Second point", "Third point"]).map((text) => ({ term: "", text }));
46
+ } else if (layout === "agenda" && out.points.length < 2) {
47
+ out.points = some(m.agenda, ["First topic", "Second topic", "Third topic"]).map((text) => ({ term: "", text }));
48
+ } else if (layout === "two_column" && !(out.left.items.length && out.right.items.length)) {
49
+ const items = some(2 * m.items, ["A point on this side", "Another point", "A point on that side", "Another point"]);
50
+ const half = Math.ceil(items.length / 2);
51
+ out.left = { label: out.left.label || "One side", items: items.slice(0, half).slice(0, m.items) };
52
+ out.right = { label: out.right.label || "The other side", items: items.slice(half).slice(0, m.items) };
53
+ } else if (layout === "stat" && !(out.stat.value && out.stat.label)) {
54
+ out.stat = { value: out.stat.value || "42%", label: out.stat.label || words[0] || "What this number means" };
55
+ } else if (layout === "quote" && !out.quote.text) {
56
+ out.quote = { text: words[0] || out.lead || "A line worth quoting.", by: out.quote.by };
57
+ } else if (layout === "closing" && !out.takeaways.length && !out.closing) {
58
+ out.takeaways = some(m.takeaways, ["First takeaway", "Second takeaway", "Third takeaway"]);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ export type AddAction = { id: string; label: string; apply: (s: EditSlide) => EditSlide };
64
+
65
+ /** The text boxes a slide can gain: only those its design has a place for (`slots`, from the server) and that it
66
+ * does not have yet, within the format's limits. */
67
+ export function addActions(s: EditSlide, slots: string[], fmt: DeckFmt): AddAction[] {
68
+ const m = max(fmt);
69
+ const has = (slot: string) => slots.includes(slot);
70
+ const act = (id: string, label: string, change: (c: EditSlide) => void): AddAction => ({
71
+ id, label, apply: (from) => {
72
+ const c = clone(from);
73
+ change(c);
74
+ return c;
75
+ },
76
+ });
77
+ const out: AddAction[] = [];
78
+ if (has("kicker") && !s.kicker) out.push(act("kicker", "Kicker", (c) => (c.kicker = "Kicker")));
79
+ if (has("lead") && !s.lead) out.push(act("lead", "Subtitle", (c) => (c.lead = "A sentence that adds context.")));
80
+ if (s.layout === "points" && has("body") && s.points.length < m.points) {
81
+ out.push(act("point", "Bullet", (c) => c.points.push({ term: "", text: "New point" })));
82
+ }
83
+ if (s.layout === "agenda" && has("body") && s.points.length < m.agenda) {
84
+ out.push(act("point", "Topic", (c) => c.points.push({ term: "", text: "New topic" })));
85
+ }
86
+ if (s.layout === "two_column") {
87
+ for (const side of ["left", "right"] as const) {
88
+ const name = side === "left" ? "Left" : "Right";
89
+ if (!s[side].label) out.push(act(`${side}-label`, `${name} heading`, (c) => (c[side].label = "Heading")));
90
+ if (s[side].items.length < m.items) out.push(act(`${side}-item`, `${name} item`, (c) => c[side].items.push("New item")));
91
+ }
92
+ }
93
+ if (s.layout === "quote" && has("by") && !s.quote.by) out.push(act("by", "Author", (c) => (c.quote.by = "Name")));
94
+ if (s.layout === "closing") {
95
+ if (has("body") && s.takeaways.length < m.takeaways) out.push(act("takeaway", "Takeaway", (c) => c.takeaways.push("New takeaway")));
96
+ if (has("closing") && !s.closing) out.push(act("closing", "Closing line", (c) => (c.closing = "One line to end on.")));
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** The text boxes on a slide that can be removed (a point only while the slide keeps enough of them; the heading, a
102
+ * stat or a quote never: the layout needs them). Each is the `data-f` of the text drawn in that box. */
103
+ export function removable(s: EditSlide): string[] {
104
+ const out: string[] = [];
105
+ if (s.kicker) out.push("kicker");
106
+ if (s.lead) out.push("lead");
107
+ if (s.quote.by) out.push("quote.by");
108
+ if (s.closing && s.takeaways.length) out.push("closing");
109
+ const minPoints = 2;
110
+ s.points.forEach((p, i) => {
111
+ if (p.term) out.push(`points.${i}.term`);
112
+ if (s.points.length > minPoints) out.push(`points.${i}.text`);
113
+ });
114
+ if (s.takeaways.length > 1 || s.closing) s.takeaways.forEach((_, i) => out.push(`takeaways.${i}`));
115
+ for (const side of ["left", "right"] as const) {
116
+ if (s[side].label) out.push(`${side}.label`);
117
+ if (s[side].items.length > 1) s[side].items.forEach((_, i) => out.push(`${side}.items.${i}`));
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** The slide without the text box at `field`: a point, takeaway or column item goes; other text is emptied. */
123
+ export function removeField(s: EditSlide, field: string): EditSlide {
124
+ const c = clone(s);
125
+ const [a, b, d] = field.split(".");
126
+ if (a === "points" && d === "text") c.points.splice(Number(b), 1);
127
+ else if (a === "points" && d === "term") c.points[Number(b)].term = "";
128
+ else if (a === "takeaways") c.takeaways.splice(Number(b), 1);
129
+ else if ((a === "left" || a === "right") && b === "items") c[a].items.splice(Number(d), 1);
130
+ else if ((a === "left" || a === "right") && b === "label") c[a].label = "";
131
+ else if (a === "quote" && b === "by") c.quote.by = "";
132
+ else if (a === "kicker" || a === "lead" || a === "closing") c[a] = "";
133
+ return c;
134
+ }
135
+
136
+ /** The slide with the text at `field` ("points.2.text", "left.items.0", "heading") set to `text`. */
137
+ export function setText(s: EditSlide, field: string, text: string): EditSlide {
138
+ const c = clone(s);
139
+ const path = field.split(".");
140
+ let at: Record<string, unknown> | unknown[] = c as unknown as Record<string, unknown>;
141
+ for (const k of path.slice(0, -1)) at = (at as Record<string, unknown>)[k] as Record<string, unknown>;
142
+ const last = path[path.length - 1];
143
+ if (Array.isArray(at)) at[Number(last)] = text;
144
+ else if (at && typeof at === "object") at[last] = text;
145
+ return c;
146
+ }
147
+
148
+ const blank = (t: string) => !t.replace(/\s+/g, "");
149
+
150
+ /** What stops the deck from being saved as it is ("Slide 3: the heading can't be empty."), or null. */
151
+ export function problem(slides: EditSlide[]): string | null {
152
+ for (const [i, s] of slides.entries()) {
153
+ const at = (what: string) => `Slide ${i + 1}: ${what} can't be empty.`;
154
+ if (blank(s.heading) && s.layout !== "quote" && s.layout !== "closing") return at("the heading");
155
+ if (s.layout === "points" || s.layout === "agenda") {
156
+ const n = s.points.findIndex((p) => blank(p.text));
157
+ if (n >= 0) return at(`${s.layout === "agenda" ? "topic" : "point"} ${n + 1}`);
158
+ }
159
+ if (s.layout === "two_column") {
160
+ for (const side of ["left", "right"] as const) {
161
+ const n = s[side].items.findIndex(blank);
162
+ if (n >= 0) return at(`the ${side} column's item ${n + 1}`);
163
+ }
164
+ }
165
+ if (s.layout === "stat" && (blank(s.stat.value) || blank(s.stat.label))) return at(blank(s.stat.value) ? "the number" : "the number's label");
166
+ if (s.layout === "quote" && blank(s.quote.text)) return at("the quote");
167
+ if (s.layout === "closing") {
168
+ const n = s.takeaways.findIndex(blank);
169
+ if (n >= 0) return at(`takeaway ${n + 1}`);
170
+ }
171
+ }
172
+ return null;
173
+ }
frontend/src/components/ui.tsx CHANGED
@@ -69,17 +69,21 @@ export function Modal({
69
  actions?: ReactNode;
70
  }) {
71
  const ref = useRef<HTMLDivElement>(null);
 
 
 
 
72
  useEffect(() => {
73
  const onKey = (e: KeyboardEvent) => {
74
  if (e.key !== "Escape") return;
75
  // A modal opened from a modal (a picker over the composer): Escape closes only the top one.
76
  const open = document.querySelectorAll(".modal-backdrop");
77
- if (open[open.length - 1] === ref.current?.parentElement) onClose();
78
  };
79
  document.addEventListener("keydown", onKey);
80
- ref.current?.focus();
81
  return () => document.removeEventListener("keydown", onKey);
82
- }, [onClose]);
83
  // Rendered into <body>: a parent with backdrop-filter or transform (cards have one) would otherwise become the
84
  // containing block of this fixed backdrop and trap the modal inside that card.
85
  return createPortal(
 
69
  actions?: ReactNode;
70
  }) {
71
  const ref = useRef<HTMLDivElement>(null);
72
+ // The latest onClose, so a parent passing a new function each render neither re-binds Escape nor (worse) pulls focus
73
+ // back to the dialog, away from whatever is being typed in.
74
+ const close = useRef(onClose);
75
+ close.current = onClose;
76
  useEffect(() => {
77
  const onKey = (e: KeyboardEvent) => {
78
  if (e.key !== "Escape") return;
79
  // A modal opened from a modal (a picker over the composer): Escape closes only the top one.
80
  const open = document.querySelectorAll(".modal-backdrop");
81
+ if (open[open.length - 1] === ref.current?.parentElement) close.current();
82
  };
83
  document.addEventListener("keydown", onKey);
84
+ ref.current?.focus(); // once, on opening
85
  return () => document.removeEventListener("keydown", onKey);
86
+ }, []);
87
  // Rendered into <body>: a parent with backdrop-filter or transform (cards have one) would otherwise become the
88
  // containing block of this fixed backdrop and trap the modal inside that card.
89
  return createPortal(
frontend/src/styles/pages.css CHANGED
@@ -8360,6 +8360,11 @@ details.map-legend[open] summary {
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;
@@ -8636,6 +8641,121 @@ details.map-legend[open] summary {
8636
  box-shadow: var(--glow-sm);
8637
  }
8638
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8639
  .sd-notes {
8640
  display: grid;
8641
  gap: 6px;
 
8360
  pointer-events: none;
8361
  }
8362
 
8363
+ /* In edit mode the slide's text is edited in place, so the frame takes the mouse */
8364
+ .sd-frame-edit iframe {
8365
+ pointer-events: auto;
8366
+ }
8367
+
8368
  /* A deck in the notebook's list: a quiet row (icon, title, "2 sources · 5d ago", menu), highlighted on hover */
8369
  .sd-item {
8370
  display: grid;
 
8641
  box-shadow: var(--glow-sm);
8642
  }
8643
 
8644
+ /* Edit mode: the slide's text boxes are clicked into, so no pointer; the slide being typed in is lit */
8645
+ .sd-slide-editing {
8646
+ cursor: default;
8647
+ }
8648
+
8649
+ .sd-slide-editing:focus-within {
8650
+ border-color: var(--blue);
8651
+ box-shadow: var(--glow-sm);
8652
+ }
8653
+
8654
+ .sd-save {
8655
+ height: 36px;
8656
+ margin-right: 6px;
8657
+ padding: 0 18px;
8658
+ }
8659
+
8660
+ .sd-edit-hint {
8661
+ color: var(--text-2);
8662
+ }
8663
+
8664
+ .sd-edit-error {
8665
+ margin: -4px 0 10px;
8666
+ }
8667
+
8668
+ /* Edit mode: each slide's controls above it (layout, text boxes to add, move, duplicate, delete) */
8669
+ .sd-tools {
8670
+ display: flex;
8671
+ flex-wrap: wrap;
8672
+ align-items: center;
8673
+ gap: 6px;
8674
+ margin-bottom: 8px;
8675
+ }
8676
+
8677
+ .sd-tools-no {
8678
+ margin-right: 4px;
8679
+ color: var(--text-2);
8680
+ font-size: 0.8125rem;
8681
+ font-weight: 600;
8682
+ }
8683
+
8684
+ .sd-tools-layout select,
8685
+ .sd-add-slide select {
8686
+ width: auto;
8687
+ height: 32px;
8688
+ padding: 0 30px 0 12px;
8689
+ font-size: 0.8125rem;
8690
+ }
8691
+
8692
+ .sd-tool {
8693
+ height: 32px;
8694
+ padding: 0 12px;
8695
+ border: 1px dashed var(--line-strong);
8696
+ border-radius: 999px;
8697
+ background: transparent;
8698
+ color: var(--text);
8699
+ font: 600 0.8125rem var(--font-body);
8700
+ cursor: pointer;
8701
+ }
8702
+
8703
+ .sd-tool:hover:not(:disabled) {
8704
+ border-color: var(--blue);
8705
+ background: var(--surface-2);
8706
+ }
8707
+
8708
+ .sd-tool:disabled {
8709
+ opacity: 0.5;
8710
+ cursor: default;
8711
+ }
8712
+
8713
+ .sd-tools-gap {
8714
+ flex: 1;
8715
+ }
8716
+
8717
+ .sd-tool-icon {
8718
+ width: 32px;
8719
+ height: 32px;
8720
+ }
8721
+
8722
+ .sd-tool-danger:hover:not(:disabled) {
8723
+ color: #ff8a8a;
8724
+ }
8725
+
8726
+ /* Between two slides in edit mode: a divider line with "+ Add slide here" on it */
8727
+ .sd-add-slide {
8728
+ display: flex;
8729
+ align-items: center;
8730
+ gap: 16px;
8731
+ margin: 20px 0 12px;
8732
+ }
8733
+
8734
+ .sd-add-slide::before,
8735
+ .sd-add-slide::after {
8736
+ content: "";
8737
+ flex: 1;
8738
+ height: 1px;
8739
+ background: var(--line-strong);
8740
+ }
8741
+
8742
+ .sd-add-slide select {
8743
+ border-style: dashed;
8744
+ }
8745
+
8746
+ /* The server is drawing the slides again: they wait, not taking clicks */
8747
+ .sd-stack.sd-busy .sd-slide {
8748
+ opacity: 0.6;
8749
+ pointer-events: none;
8750
+ transition: opacity 0.15s;
8751
+ }
8752
+
8753
+ .sd-notes-edit {
8754
+ min-height: 84px;
8755
+ resize: vertical;
8756
+ line-height: 1.6;
8757
+ }
8758
+
8759
  .sd-notes {
8760
  display: grid;
8761
  gap: 6px;