slidemind 0.3.5 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,25 +18,19 @@
18
18
  // round send button (a "story iteration" pill while storyline edits are staged)
19
19
  // and — fused to its right — the Approve button, whose sub-label says the agent
20
20
  // builds the slides next. Above it, the LEDGER rail lists every comment of this
21
- // session with its anchor and status (queued → sent → addressed).
21
+ // session with its anchor and status (sent → addressed).
22
22
  //
23
23
  // TWO MODES, not "reviewing vs. locked out":
24
24
  // - PARKED (the command is blocking on this tab): everything is live. Send
25
- // resolves the gate at once (decision "comments", carrying the anchored
26
- // comment plus any queued stragglers); Approve resolves it as "approve";
27
- // direct edits and story iteration are allowed.
25
+ // resolves the gate at once (decision "comments"); Approve resolves it as
26
+ // "approve"; direct edits and story iteration are allowed.
28
27
  // - WORKING (the agent is revising): the plan stays readable and the strip
29
- // stays navigable, and the composer stays LIVE — sent comments QUEUE in
30
- // sessionStorage (ledger status "queued") instead of hitting the server.
31
- // Direct edits, story iteration and Approve are off (the displayed round is
32
- // already superseded). A status strip says so.
33
- //
34
- // Queued comments are never lost: the tab polls the heartbeat, and when the
35
- // agent's next `slidemind plan` run claims it the reload's boot POSTs the whole
36
- // backlog to /plan-review/backlog — which resolves that run immediately as a
37
- // drain round, so the tab simply stays in working mode with the ledger flipped
38
- // to "sent". A tab that is superseded by a fresh window flushes its backlog
39
- // before retiring, for the same reason.
28
+ // stays navigable; the composer, direct edits, story iteration and Approve
29
+ // are off (the displayed round is already superseded). A status strip says
30
+ // so — the add-in's "Revising the plan / this window refreshes when it's
31
+ // ready". Nothing is queued behind the agent's back: the tab polls the
32
+ // heartbeat and reloads itself when the next `slidemind plan` run presents
33
+ // the revised round.
40
34
  //
41
35
  // A round transition is detected from the SERVER's per-run nonce (CFG.round):
42
36
  // a different nonce means a newer `slidemind plan` run served this page. It
@@ -44,7 +38,8 @@
44
38
  // unsent draft, shows a transient banner, and diffs the cached previous plan
45
39
  // body per slide to badge the changed tabs "updated" — the body diff is
46
40
  // COSMETIC only; it never decides whether a round arrived. The web notification
47
- // fires ONLY when the arriving round PARKS — drain rounds land silently.
41
+ // fires when the arriving round parks (a plain refresh of a sent round stays
42
+ // silent).
48
43
  //
49
44
  // A fresh window (no session of its own) seeds its ledger from CFG.ledgerSeed,
50
45
  // the deck's persisted plan-review.json round, so the reviewer still sees the
@@ -79,8 +74,8 @@
79
74
 
80
75
  // The shared gate session (src/builders/gate-session.js, inlined into the page
81
76
  // as window.__SLIDEMIND_GATE__): the deck-keyed sessionStorage ledger, the
82
- // backlog flush, the server-nonce round transition, the round-ready announce
83
- // and the heartbeat poll — one implementation, shared with the preview gate.
77
+ // server-nonce round transition, the round-ready announce and the heartbeat
78
+ // poll — one implementation, shared with the preview gate.
84
79
  var SES = window.__SLIDEMIND_GATE__.createGateSession({
85
80
  name: "plan",
86
81
  deck: CFG.deck || "",
@@ -91,7 +86,6 @@
91
86
  round: CFG.round || "",
92
87
  service: "slidemind-plan",
93
88
  heartbeat: CFG.heartbeat || "/__plan/heartbeat",
94
- backlogEndpoint: CFG.backlogEndpoint || "/plan-review/backlog",
95
89
  pollMs: CFG.pollMs,
96
90
  pollTtlMs: CFG.pollTtlMs,
97
91
  attentionTitle: "● Plan ready — slidemind",
@@ -105,9 +99,9 @@
105
99
  // ---------- state ----------
106
100
  var submitted = false;
107
101
  var approveArmed = false; // inline "discard unsent feedback?" confirm state
108
- var working = false; // the agent is revising → edits off, comments queue
102
+ var working = false; // the agent is revising → edits, composer and approve off
109
103
  var baseline = CFG.planBody || ""; // the served plan body; edits diff against it
110
- var ledger = []; // every comment of this session: {…, status:queued|sent|addressed}
104
+ var ledger = []; // every comment of this session: {…, status:sent|addressed}
111
105
  var roundNo = 1; // this tab's round counter (sessionStorage, not server state)
112
106
  var changedSlides = {}; // 1-based tab position → true ("updated" badges this round)
113
107
  var bannerText = null; // transient round-arrival banner, null once dismissed
@@ -297,24 +291,37 @@
297
291
  ".plan-storyboard-draft-note p{margin:0;}.plan-storyboard-draft-note strong{color:var(--sm-fg);font-weight:600;}" +
298
292
  /* storyline tab strip */
299
293
  ".plan-storyline{margin:0;}" +
300
- ".plan-storyline-head{display:flex;flex-direction:column;align-items:flex-start;gap:3px;padding:16px 20px 12px;}" +
294
+ ".plan-storyline-head{display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:10px 20px;padding:16px 20px 12px;}" +
295
+ ".plan-storyline-head-text{display:flex;flex:1 1 240px;flex-direction:column;gap:3px;min-width:0;}" +
296
+ /* deck-level layout knob: one pick binds every slide's layout */
297
+ ".plan-storyline-deck-layout{display:flex;flex:0 1 280px;flex-direction:column;gap:4px;min-width:200px;}" +
298
+ ".plan-storyline-deck-layout-label{color:var(--sm-dim);font-size:9px;font-weight:700;letter-spacing:.18em;line-height:1.2;text-transform:uppercase;}" +
299
+ ".plan-storyline-deck-layout .plan-storyboard-layout-picker{margin:0;}" +
300
+ ".plan-storyline-deck-layout .plan-storyboard-layout-menu{left:auto;right:0;min-width:340px;}" +
301
301
  ".plan-storyline-label{color:var(--plan-story);font-size:9px;font-weight:700;letter-spacing:.18em;line-height:1.2;text-transform:uppercase;}" +
302
302
  ".plan-storyline-track{display:flex;flex-wrap:wrap;align-items:stretch;margin:0;padding:4px 14px 10px;list-style:none;" +
303
303
  "border-bottom:1px solid var(--sm-border);row-gap:2px;}" +
304
- ".plan-storyline-beat{position:relative;display:flex;flex:1 1 150px;min-width:132px;max-width:260px;flex-direction:row;" +
305
- "align-items:baseline;gap:9px;padding:8px 8px 9px;border:0;border-radius:8px;background:transparent;cursor:pointer;transition:background-color 130ms ease;}" +
304
+ /* grid: numeral + move text share a baseline row; the layout chip fills the
305
+ second row and pins to the bottom, so cards in a row stay one height */
306
+ ".plan-storyline-beat{position:relative;display:grid;flex:1 1 150px;min-width:132px;max-width:260px;grid-template-columns:auto minmax(0,1fr);grid-template-rows:auto 1fr;" +
307
+ "align-items:baseline;column-gap:9px;padding:8px 8px 9px;border:0;border-radius:8px;background:transparent;cursor:pointer;transition:background-color 130ms ease;}" +
306
308
  ".plan-storyline-beat:hover{background:var(--sm-muted);}" +
307
309
  ".plan-storyline-beat--active{background:color-mix(in srgb,var(--sm-fg) 8%,transparent);}" +
308
310
  ".plan-storyline-beat--draggable{touch-action:none;}" +
309
311
  ".plan-storyline-beat--dragging{z-index:20;cursor:grabbing;background:var(--sm-surface);box-shadow:0 16px 36px rgba(0,0,0,.5);transition:none;}" +
310
312
  ".plan-storyline-beat--editing{cursor:text;}" +
311
- ".plan-storyline-beat-slide{flex:none;color:color-mix(in srgb,var(--sm-dim) 55%,transparent);" +
313
+ ".plan-storyline-beat-slide{grid-row:1;grid-column:1;color:color-mix(in srgb,var(--sm-dim) 55%,transparent);" +
312
314
  "font-family:Georgia,'Times New Roman',serif;font-size:21px;font-weight:400;line-height:1;transition:color 130ms ease;}" +
313
315
  ".plan-storyline-beat--active .plan-storyline-beat-slide{color:var(--plan-story);}" +
314
- ".plan-storyline-beat-text{display:-webkit-box;min-width:0;overflow:hidden;color:var(--sm-dim);font-size:12px;" +
316
+ ".plan-storyline-beat-text{grid-row:1;grid-column:2;display:-webkit-box;min-width:0;overflow:hidden;color:var(--sm-dim);font-size:12px;" +
315
317
  "font-weight:500;line-height:1.35;-webkit-box-orient:vertical;-webkit-line-clamp:2;}" +
316
318
  ".plan-storyline-beat--active .plan-storyline-beat-text{color:var(--sm-fg);font-weight:600;}" +
317
319
  ".plan-storyline-beat-text[contenteditable=\"true\"]{display:block;border-radius:4px;cursor:text;outline:none;-webkit-line-clamp:unset;}" +
320
+ ".plan-storyboard-layout-picker--beat{grid-row:2;grid-column:2;align-self:end;min-width:0;margin:5px 0 0;max-width:100%;}" +
321
+ ".plan-storyboard-layout-picker--beat .plan-storyboard-layout-current{width:auto;max-width:100%;gap:6px;border-color:transparent;border-radius:5px;padding:2px 4px;margin-left:-4px;color:var(--sm-dim);font-size:10.5px;font-weight:500;}" +
322
+ ".plan-storyboard-layout-picker--beat .plan-storyboard-layout-current:not(:disabled):hover{border-color:var(--sm-line);}" +
323
+ ".plan-storyboard-layout-picker--beat .plan-storyboard-layout-current>.plan-storyboard-layout-thumb{width:36px;height:20px;}" +
324
+ ".plan-storyboard-layout-picker--beat .plan-storyboard-layout-menu{right:auto;min-width:320px;}" +
318
325
  /* "updated" badge — this slide changed in the round that just arrived */
319
326
  ".plan-storyline-beat-updated{position:absolute;top:-6px;left:6px;border-radius:999px;padding:0 6px;" +
320
327
  "background:color-mix(in srgb,var(--sm-accent) 22%,var(--sm-bg));color:var(--sm-accent);" +
@@ -357,8 +364,8 @@
357
364
  "linear-gradient(90deg,color-mix(in srgb,var(--plan-annotation) 13%,transparent) 1px,transparent 1px);" +
358
365
  "background-size:calc(100%/12) calc(100%/6);background-color:color-mix(in srgb,#3565d4 3%,transparent);}" +
359
366
  ".plan-storyboard-block{position:absolute;display:flex;min-width:0;min-height:0;flex-direction:column;gap:clamp(4px,.72vw,7px);" +
360
- "overflow:hidden;border:1.5px dashed color-mix(in srgb,var(--plan-annotation) 44%,transparent);border-radius:6px;" +
361
- "background:color-mix(in srgb,var(--sm-surface) 62%,transparent);padding:clamp(6px,.95vw,10px);}" +
367
+ "overflow:hidden;border:1.5px dashed color-mix(in srgb,var(--plan-annotation) 55%,transparent);border-radius:6px;" +
368
+ "background:color-mix(in srgb,var(--sm-surface) 94%,transparent);box-shadow:0 1px 4px rgba(15,23,42,.12);padding:clamp(6px,.95vw,10px);}" +
362
369
  ".plan-storyboard-block[data-emphasis=\"primary\"]{border-width:2px;border-color:color-mix(in srgb,var(--plan-annotation) 70%,transparent);}" +
363
370
  ".plan-storyboard-block[data-emphasis=\"tertiary\"]{opacity:.78;}" +
364
371
  ".plan-storyboard-block--movable{cursor:grab;touch-action:none;}" +
@@ -392,6 +399,40 @@
392
399
  ".plan-storyboard-field[data-field=\"content\"] .plan-storyboard-field-control{color:var(--sm-fg);font-weight:500;line-height:1.18;}" +
393
400
  ".plan-storyboard-detail{position:sticky;top:0;max-height:min(62vh,620px);overflow:auto;padding:30px 0 0;}" +
394
401
  ".plan-storyboard-detail-label{margin:0 0 5px;color:var(--sm-dim);font-size:10px;font-weight:700;letter-spacing:.04em;line-height:1.2;text-transform:uppercase;}" +
402
+ ".plan-storyboard-frame--chrome{background:#fff;}" +
403
+ ".plan-storyboard-chrome{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;opacity:.95;}" +
404
+ ".plan-storyboard-zones{position:absolute;inset:0;pointer-events:none;}" +
405
+ ".plan-storyboard-zone-wash{position:absolute;background:rgba(15,23,42,.16);}" +
406
+ ".plan-storyboard-zone-notice-layer{position:absolute;inset:0;z-index:30;pointer-events:none;}" +
407
+ ".plan-storyboard-zone-canvas{position:absolute;border:2px solid rgba(53,101,212,.8);border-radius:4px;box-shadow:0 0 0 1px rgba(255,255,255,.55);}" +
408
+ ".plan-storyboard-zone-label{position:absolute;top:-1px;left:-1px;border-radius:3px 0 4px 0;background:rgba(53,101,212,.85);padding:1px 7px;color:#fff;font-size:9px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;}" +
409
+ ".plan-storyboard-zone-notice{position:absolute;top:10px;left:50%;transform:translateX(-50%);max-width:78%;border-radius:999px;background:rgba(15,23,42,.82);padding:5px 14px;color:#fff;font-size:11px;font-weight:600;line-height:1.35;text-align:center;}" +
410
+ ".plan-storyboard-ph{position:absolute;border:1px solid rgba(15,23,42,.5);border-radius:3px;background:rgba(255,255,255,.12);box-shadow:0 0 0 1px rgba(255,255,255,.55);}" +
411
+ ".plan-storyboard-ph-label{position:absolute;top:-1px;left:-1px;border-radius:2px 0 3px 0;background:rgba(15,23,42,.78);padding:0 5px;color:#fff;font-size:8px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;box-shadow:0 0 0 1px rgba(255,255,255,.5);}" +
412
+ ".plan-storyboard-image-layer{position:absolute;inset:0;pointer-events:none;}" +
413
+ ".plan-storyboard-image-slot{position:absolute;display:flex;flex-direction:column;pointer-events:auto;border:1px dashed rgba(53,101,212,.75);border-radius:4px;background:rgba(255,255,255,.55);overflow:hidden;}" +
414
+ ".plan-storyboard-image-slot--filled{border-style:solid;background:rgba(255,255,255,.86);}" +
415
+ ".plan-storyboard-image-caption{flex:none;padding:1px 5px;background:rgba(53,101,212,.85);color:#fff;font-size:8px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;}" +
416
+ ".plan-storyboard-image-input{flex:1;width:100%;min-height:0;resize:none;border:0;background:transparent;padding:4px 5px;font:inherit;font-size:10px;line-height:1.3;color:#0f172a;}" +
417
+ ".plan-storyboard-image-input:focus{outline:none;}" +
418
+ ".plan-storyboard-image-input::placeholder{color:rgba(15,23,42,.45);font-style:italic;}" +
419
+ ".plan-storyboard-layout-picker{position:relative;margin:0 0 18px;}" +
420
+ ".plan-storyboard-layout-current{display:flex;align-items:center;gap:8px;width:100%;border:1px solid var(--sm-line);border-radius:8px;background:transparent;padding:6px 8px;color:var(--sm-fg);font-size:12.5px;font-weight:550;text-align:left;cursor:pointer;font-family:inherit;}" +
421
+ ".plan-storyboard-layout-current:disabled{cursor:default;opacity:.85;}" +
422
+ ".plan-storyboard-layout-current:not(:disabled):hover{background:rgba(255,255,255,.05);}" +
423
+ ".plan-storyboard-layout-thumb{width:52px;height:30px;flex:none;border:1px solid var(--sm-line);border-radius:4px;object-fit:cover;background:#fff;}" +
424
+ ".plan-storyboard-layout-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}" +
425
+ ".plan-storyboard-layout-chevron{flex:none;margin-left:auto;color:var(--sm-dim);font-size:11px;line-height:1;}" +
426
+ ".plan-storyboard-layout-unknown{flex:none;border-radius:999px;background:rgba(217,75,75,.14);padding:1px 7px;color:#e07272;font-size:10px;font-weight:650;}" +
427
+ ".plan-storyboard-layout-menu{position:absolute;z-index:30;top:calc(100% + 4px);left:0;right:0;max-height:300px;overflow:auto;border:1px solid var(--sm-line);border-radius:10px;background:var(--sm-bg);box-shadow:0 10px 32px rgba(0,0,0,.35);padding:4px;}" +
428
+ ".plan-storyboard-layout-option{display:flex;align-items:center;gap:8px;width:100%;border:0;border-radius:7px;background:transparent;padding:6px;color:var(--sm-fg);text-align:left;cursor:pointer;font-family:inherit;}" +
429
+ ".plan-storyboard-layout-option:hover{background:rgba(255,255,255,.06);}" +
430
+ ".plan-storyboard-layout-option--selected{background:rgba(255,255,255,.09);}" +
431
+ ".plan-storyboard-layout-thumb--option{width:72px;height:41px;}" +
432
+ ".plan-storyboard-layout-option-text{display:flex;min-width:0;flex-direction:column;gap:1px;}" +
433
+ ".plan-storyboard-layout-option-text .plan-storyboard-layout-name{font-size:12.5px;font-weight:600;}" +
434
+ ".plan-storyboard-layout-purpose{overflow:hidden;color:var(--sm-dim);font-size:11px;line-height:1.3;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}" +
435
+ ".plan-storyboard-layout-canvas{color:var(--sm-dim);font-size:10px;font-variant-numeric:tabular-nums;}" +
395
436
  ".plan-storyboard-key-message{margin:0 0 18px;color:var(--sm-fg);font-size:15px;font-weight:650;line-height:1.34;white-space:pre-wrap;word-break:break-word;outline:none;}" +
396
437
  ".plan-storyboard-detail-text{min-height:9em;border:0;background:transparent;color:var(--sm-fg);font-size:13.5px;font-weight:400;line-height:1.42;outline:none;white-space:pre-wrap;word-break:break-word;}" +
397
438
  ".plan-storyboard-warnings{margin:.75em 0 0;padding-left:1.2em;font-size:.86em;color:var(--sm-dim);}" +
@@ -419,7 +460,6 @@
419
460
  ".sm-ledger-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}" +
420
461
  ".sm-ledger-status{flex:none;border-radius:999px;padding:1px 8px;font-size:10px;font-weight:600;" +
421
462
  "letter-spacing:.04em;text-transform:uppercase;border:1px solid var(--sm-border);color:var(--sm-dim);}" +
422
- ".sm-ledger-status[data-state=\"queued\"]{color:var(--sm-warn);border-color:rgba(246,196,84,.4);}" +
423
463
  ".sm-ledger-status[data-state=\"sent\"]{color:var(--sm-accent);border-color:rgba(169,194,255,.4);}" +
424
464
  ".sm-ledger-status[data-state=\"addressed\"]{color:var(--sm-success);border-color:rgba(46,204,113,.4);}" +
425
465
  /* scope control */
@@ -439,6 +479,7 @@
439
479
  "background:var(--sm-accent);color:#0f1220;transition:transform .14s ease,box-shadow .14s ease,opacity .14s ease;}" +
440
480
  ".sm-send svg{width:16px;height:16px;margin:1px 1px 0 0;}" +
441
481
  ".sm-send:hover{transform:translateY(-1px) scale(1.05);box-shadow:0 8px 22px rgba(169,194,255,.35);}" +
482
+ ".sm-send:disabled,.sm-chat-ta:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none;}" +
442
483
  ".sm-send.is-off{opacity:.35;cursor:not-allowed;pointer-events:none;}" +
443
484
  /* story-iteration pill (send while a storyline draft is staged) */
444
485
  ".sm-send-pill{display:inline-flex;align-items:center;gap:7px;height:38px;flex-shrink:0;border:0;border-radius:999px;" +
@@ -532,7 +573,7 @@
532
573
  // the unsent draft (the server nonce is added by the shared session). The
533
574
  // plan body is NOT in here — see BODY_KEY.
534
575
  function ctxFields() {
535
- return { round: roundNo, selectedIndex: S.selectedIndex, draft: feedbackText };
576
+ return { round: roundNo, selectedIndex: S.selectedIndex, draft: feedbackText, working: working };
536
577
  }
537
578
  function saveCtx() {
538
579
  SES.saveCtx(ctxFields());
@@ -541,12 +582,6 @@
541
582
  function saveDraftSoon() {
542
583
  SES.saveCtxSoon(ctxFields());
543
584
  }
544
- function queuedComments() {
545
- return SES.queued(ledger);
546
- }
547
- function flushBacklog() {
548
- return SES.flushBacklog(ledger);
549
- }
550
585
 
551
586
  // Per-slide segment diff between the previous round's plan body and this one,
552
587
  // keyed by the slide NUMBER the `### Slide N` heading declares — never by the
@@ -633,7 +668,7 @@
633
668
  if (editedBadge) editedBadge.style.display = planWasEdited() ? "" : "none";
634
669
  if (hintEl) {
635
670
  hintEl.textContent = working
636
- ? "The agent is revising · read on, browse the strip, keep commenting — editing resumes with the next round"
671
+ ? "The agent is revising · read on and browse the strip — commenting and editing resume with the next round"
637
672
  : "Shape the red thread and storyboards below · drag to reorder, rename moves, edit blocks · decide when it reads right";
638
673
  }
639
674
 
@@ -655,16 +690,6 @@
655
690
  sub.style.display = "";
656
691
  sub.textContent = "The agent is revising — approve the next round";
657
692
  }
658
- } else if (queuedComments().length) {
659
- // Only reachable when a backlog flush failed: approving would drop them.
660
- approveArmed = false;
661
- approveBtn.classList.remove("is-armed");
662
- approveBtn.classList.add("is-off");
663
- if (main) main.textContent = "Approve plan";
664
- if (sub) {
665
- sub.style.display = "";
666
- sub.textContent = "Send your queued comments first";
667
- }
668
693
  } else if (S.storyDraft) {
669
694
  // Approve is blocked until the staged storyline is sent or discarded.
670
695
  approveArmed = false;
@@ -872,7 +897,7 @@
872
897
  dossier.appendChild(root);
873
898
 
874
899
  root.appendChild(
875
- renderStrip(slides, committed, beatMessages, storyboardData, canEdit, activeIndex),
900
+ renderStrip(slides, committed, beatMessages, storyboardData, storyboardSegments, canEdit, activeIndex),
876
901
  );
877
902
 
878
903
  var body = el("div", "plan-dossier-body");
@@ -910,10 +935,13 @@
910
935
  }
911
936
 
912
937
  // ---------- storyline tab strip ----------
913
- function renderStrip(slides, committed, committedMessages, storyboardData, canEdit, activeIndex) {
938
+ function renderStrip(slides, committed, committedMessages, storyboardData, storyboardSegments, canEdit, activeIndex) {
914
939
  var section = el("section", "plan-storyline");
915
940
  var head = el("div", "plan-storyline-head");
916
- head.appendChild(el("span", "plan-storyline-label", "Red thread"));
941
+ var headText = el("div", "plan-storyline-head-text");
942
+ headText.appendChild(el("span", "plan-storyline-label", "Red thread"));
943
+ head.appendChild(headText);
944
+ head.appendChild(renderDeckLayoutKnob(storyboardData));
917
945
  section.appendChild(head);
918
946
 
919
947
  var track = el("ol", "plan-storyline-track");
@@ -1094,6 +1122,24 @@
1094
1122
  });
1095
1123
  }
1096
1124
  li.appendChild(textEl);
1125
+ // Layout chip: the board's layout, picked in place. A tab resolves to the
1126
+ // board of the slide it originally was; staged new slides have no board.
1127
+ var chipIndex = S.storyDraft ? item.originalIndex : index;
1128
+ var chipSegment = chipIndex === null || chipIndex === undefined ? undefined : storyboardSegments[chipIndex];
1129
+ if (chipSegment && chipSegment.data) {
1130
+ var chip = renderLayoutPicker(chipSegment.data.layout, {
1131
+ className: "plan-storyboard-layout-picker--beat",
1132
+ ariaLabel: "Choose the layout of slide " + (index + 1),
1133
+ onPick: function (componentName) {
1134
+ var d = {};
1135
+ for (var k in chipSegment.data) d[k] = chipSegment.data[k];
1136
+ d.layout = componentName;
1137
+ commitStoryboard(chipSegment, d);
1138
+ },
1139
+ });
1140
+ chip.setAttribute("data-no-drag", "");
1141
+ li.appendChild(chip);
1142
+ }
1097
1143
 
1098
1144
  if (canEdit) {
1099
1145
  var rm = el("button", "plan-storyline-beat-remove", ICONS.x);
@@ -1237,13 +1283,16 @@
1237
1283
  // Commit a storyboard mutation into S.value and re-render the surface. Safe to
1238
1284
  // full-re-render because every canvas edit commits on blur / pointerup, so no
1239
1285
  // field is focused at commit time.
1240
- function commitStoryboard(segment, nextData) {
1286
+ function commitPlan(nextValue) {
1241
1287
  if (locked()) return;
1242
- S.value = PLAN.replaceStoryboardSegment(S.value, segment, nextData);
1288
+ S.value = nextValue;
1243
1289
  approveArmed = false; // a storyboard edit disarms a pending approve-confirm
1244
1290
  refreshChrome();
1245
1291
  renderSurface();
1246
1292
  }
1293
+ function commitStoryboard(segment, nextData) {
1294
+ commitPlan(PLAN.replaceStoryboardSegment(S.value, segment, nextData));
1295
+ }
1247
1296
 
1248
1297
  function renderField(variant, value, onCommit) {
1249
1298
  var wrap = el("div", "plan-storyboard-field");
@@ -1286,13 +1335,43 @@
1286
1335
  return wrap;
1287
1336
  }
1288
1337
 
1338
+ // Where the generic body-safe area lands on the frame. Blocks are planned in
1339
+ // the 1600×900 stage inside BODY_SAFE_AREA; a bound layout's content zone
1340
+ // (normalized 0..1) IS the real body, so the safe area is stretched onto it —
1341
+ // every block reads as a fraction of the body, never as a bare stage position
1342
+ // that may fall outside the template's content area. Without a zone the
1343
+ // stage is the frame. {x,y} = normalized frame offset, {sx,sy} = normalized
1344
+ // frame units per stage unit.
1345
+ function frameMapFor(data) {
1346
+ var selected = selectedPlanLayout(data);
1347
+ var zone = selected && selected.canvasBounds;
1348
+ // A zero-size zone would make the per-pixel scale infinite and a drag
1349
+ // write NaN coordinates — treat it as no zone.
1350
+ if (!zone || !(zone.w > 0) || !(zone.h > 0)) {
1351
+ return { x: 0, y: 0, sx: 1 / PLAN.STORYBOARD_WIDTH, sy: 1 / PLAN.STORYBOARD_HEIGHT };
1352
+ }
1353
+ var A = PLAN.BODY_SAFE_AREA;
1354
+ var sx = zone.w / A.w;
1355
+ var sy = zone.h / A.h;
1356
+ return { x: zone.x - A.x * sx, y: zone.y - A.y * sy, sx: sx, sy: sy };
1357
+ }
1358
+ function placeBlock(node, map, x, y, w, h) {
1359
+ node.style.left = (map.x + x * map.sx) * 100 + "%";
1360
+ node.style.top = (map.y + y * map.sy) * 100 + "%";
1361
+ node.style.width = w * map.sx * 100 + "%";
1362
+ node.style.height = h * map.sy * 100 + "%";
1363
+ }
1364
+ // Stage units per client pixel, per axis, for a 16:9 frame `width` px wide.
1365
+ function stagePerPixel(map, width) {
1366
+ var height = (width * PLAN.STORYBOARD_HEIGHT) / PLAN.STORYBOARD_WIDTH;
1367
+ return { x: 1 / (width * map.sx), y: 1 / (height * map.sy) };
1368
+ }
1369
+
1289
1370
  function renderBlock(frameEl, block, data, segment, canRemove) {
1290
1371
  var node = el("div", "plan-storyboard-block" + (locked() ? "" : " plan-storyboard-block--movable"));
1291
1372
  node.setAttribute("data-emphasis", block.emphasis || "secondary");
1292
- node.style.left = (block.x / PLAN.STORYBOARD_WIDTH) * 100 + "%";
1293
- node.style.top = (block.y / PLAN.STORYBOARD_HEIGHT) * 100 + "%";
1294
- node.style.width = (block.w / PLAN.STORYBOARD_WIDTH) * 100 + "%";
1295
- node.style.height = (block.h / PLAN.STORYBOARD_HEIGHT) * 100 + "%";
1373
+ var map = frameMapFor(data);
1374
+ placeBlock(node, map, block.x, block.y, block.w, block.h);
1296
1375
 
1297
1376
  function change(patch) {
1298
1377
  commitStoryboard(segment, updateBlock(data, block.id, patch));
@@ -1335,13 +1414,13 @@
1335
1414
 
1336
1415
  if (!locked()) {
1337
1416
  node.title = "Drag to reposition";
1338
- wireBlockDrag(node, frameEl, block, change);
1417
+ wireBlockDrag(node, frameEl, block, map, change);
1339
1418
  var rz = el("button", "plan-storyboard-block-resize");
1340
1419
  rz.type = "button";
1341
1420
  rz.setAttribute("data-no-drag", "");
1342
1421
  rz.title = "Drag to resize";
1343
1422
  rz.setAttribute("aria-label", "Drag to resize");
1344
- wireBlockResize(rz, frameEl, block, change);
1423
+ wireBlockResize(rz, frameEl, block, map, change);
1345
1424
  node.appendChild(rz);
1346
1425
  }
1347
1426
  return node;
@@ -1361,7 +1440,7 @@
1361
1440
  h: clamp(h, PLAN.MIN_BLOCK_HEIGHT, A.y + A.h - block.y),
1362
1441
  };
1363
1442
  }
1364
- function wireBlockDrag(node, frameEl, block, change) {
1443
+ function wireBlockDrag(node, frameEl, block, map, change) {
1365
1444
  var st = null;
1366
1445
  node.addEventListener("pointerdown", function (e) {
1367
1446
  if (locked() || e.button !== 0) return;
@@ -1374,7 +1453,7 @@
1374
1453
  oy: e.clientY,
1375
1454
  sx: block.x,
1376
1455
  sy: block.y,
1377
- scale: PLAN.STORYBOARD_WIDTH / rect.width,
1456
+ scale: stagePerPixel(map, rect.width),
1378
1457
  moved: false,
1379
1458
  };
1380
1459
  try {
@@ -1390,9 +1469,8 @@
1390
1469
  if (!st.moved && Math.hypot(dx, dy) < BLOCK_DRAG_THRESHOLD) return;
1391
1470
  st.moved = true;
1392
1471
  node.classList.add("plan-storyboard-block--dragging");
1393
- var p = clampPos(block, st.sx + dx * st.scale, st.sy + dy * st.scale);
1394
- node.style.left = (p.x / PLAN.STORYBOARD_WIDTH) * 100 + "%";
1395
- node.style.top = (p.y / PLAN.STORYBOARD_HEIGHT) * 100 + "%";
1472
+ var p = clampPos(block, st.sx + dx * st.scale.x, st.sy + dy * st.scale.y);
1473
+ placeBlock(node, map, p.x, p.y, block.w, block.h);
1396
1474
  });
1397
1475
  function end(e) {
1398
1476
  if (!st || st.pointerId !== e.pointerId) return;
@@ -1407,7 +1485,7 @@
1407
1485
  node.classList.remove("plan-storyboard-block--dragging");
1408
1486
  return;
1409
1487
  }
1410
- var p = clampPos(block, s.sx + (e.clientX - s.ox) * s.scale, s.sy + (e.clientY - s.oy) * s.scale);
1488
+ var p = clampPos(block, s.sx + (e.clientX - s.ox) * s.scale.x, s.sy + (e.clientY - s.oy) * s.scale.y);
1411
1489
  var x = Math.round(p.x);
1412
1490
  var y = Math.round(p.y);
1413
1491
  if (x !== block.x || y !== block.y) change({ x: x, y: y });
@@ -1420,7 +1498,7 @@
1420
1498
  node.classList.remove("plan-storyboard-block--dragging");
1421
1499
  });
1422
1500
  }
1423
- function wireBlockResize(handle, frameEl, block, change) {
1501
+ function wireBlockResize(handle, frameEl, block, map, change) {
1424
1502
  var st = null;
1425
1503
  handle.addEventListener("pointerdown", function (e) {
1426
1504
  if (locked() || e.button !== 0) return;
@@ -1433,7 +1511,7 @@
1433
1511
  oy: e.clientY,
1434
1512
  sw: block.w,
1435
1513
  sh: block.h,
1436
- scale: PLAN.STORYBOARD_WIDTH / rect.width,
1514
+ scale: stagePerPixel(map, rect.width),
1437
1515
  moved: false,
1438
1516
  };
1439
1517
  try {
@@ -1449,10 +1527,9 @@
1449
1527
  if (!st.moved && Math.hypot(dx, dy) < BLOCK_DRAG_THRESHOLD) return;
1450
1528
  st.moved = true;
1451
1529
  var node = handle.parentNode;
1452
- var p = clampSize(block, st.sw + dx * st.scale, st.sh + dy * st.scale);
1530
+ var p = clampSize(block, st.sw + dx * st.scale.x, st.sh + dy * st.scale.y);
1453
1531
  node.classList.add("plan-storyboard-block--dragging");
1454
- node.style.width = (p.w / PLAN.STORYBOARD_WIDTH) * 100 + "%";
1455
- node.style.height = (p.h / PLAN.STORYBOARD_HEIGHT) * 100 + "%";
1532
+ placeBlock(node, map, block.x, block.y, p.w, p.h);
1456
1533
  });
1457
1534
  function end(e) {
1458
1535
  if (!st || st.pointerId !== e.pointerId) return;
@@ -1464,7 +1541,7 @@
1464
1541
  var s = st;
1465
1542
  st = null;
1466
1543
  if (!s.moved) return;
1467
- var p = clampSize(block, s.sw + (e.clientX - s.ox) * s.scale, s.sh + (e.clientY - s.oy) * s.scale);
1544
+ var p = clampSize(block, s.sw + (e.clientX - s.ox) * s.scale.x, s.sh + (e.clientY - s.oy) * s.scale.y);
1468
1545
  var w = Math.round(p.w);
1469
1546
  var h = Math.round(p.h);
1470
1547
  if (w !== block.w || h !== block.h) change({ w: w, h: h });
@@ -1508,8 +1585,161 @@
1508
1585
  return field;
1509
1586
  }
1510
1587
 
1588
+ // Layout picker: current choice (chrome thumbnail + name) plus a dropdown of
1589
+ // the bundle's layouts (CFG.layouts; empty → read-only). Used per slide in
1590
+ // the details aside and once for the deck-level knob; `opts.onPick` owns the
1591
+ // commit. All labels/names go through textContent — bundle strings never
1592
+ // reach innerHTML.
1593
+ // One short line per option instead of the codegen's geometry dump: the
1594
+ // count is what a human picks on; the exact rectangles are drawn on the
1595
+ // storyboard frame once the layout is chosen.
1596
+ function contentAreaSummary(option) {
1597
+ var n = option.contentAreas ? option.contentAreas.length : 0;
1598
+ if (!n && option.canvas) n = (option.canvas.match(/(?:^|;)\s*\d+\./g) || []).length || 1;
1599
+ if (!n) return "Chrome only";
1600
+ return n === 1 ? "1 content area" : n + " content areas";
1601
+ }
1602
+
1603
+ function renderLayoutPicker(value, opts) {
1604
+ var options = Array.isArray(CFG.layouts) ? CFG.layouts : [];
1605
+ var root = el("div", "plan-storyboard-layout-picker" + (opts.className ? " " + opts.className : ""));
1606
+ var selected = null;
1607
+ for (var i = 0; i < options.length; i++) {
1608
+ if (options[i].componentName === value) selected = options[i];
1609
+ }
1610
+ var canPick = !locked() && options.length > 0;
1611
+
1612
+ var current = document.createElement("button");
1613
+ current.type = "button";
1614
+ current.className = "plan-storyboard-layout-current";
1615
+ current.setAttribute("aria-label", opts.ariaLabel || "Choose the slide's layout");
1616
+ current.disabled = !canPick;
1617
+ if (selected && selected.previewUrl) {
1618
+ var thumb = document.createElement("img");
1619
+ thumb.className = "plan-storyboard-layout-thumb";
1620
+ thumb.src = selected.previewUrl;
1621
+ thumb.alt = "";
1622
+ thumb.setAttribute("aria-hidden", "true");
1623
+ current.appendChild(thumb);
1624
+ }
1625
+ var nameSpan = el("span", "plan-storyboard-layout-name");
1626
+ nameSpan.textContent = value || opts.placeholder || "No layout chosen";
1627
+ current.appendChild(nameSpan);
1628
+ if (value && options.length > 0 && !selected) {
1629
+ var badge = el("span", "plan-storyboard-layout-unknown");
1630
+ badge.textContent = "Not in this brand";
1631
+ current.appendChild(badge);
1632
+ }
1633
+ if (canPick) {
1634
+ var chevron = el("span", "plan-storyboard-layout-chevron", "\u25BE");
1635
+ chevron.setAttribute("aria-hidden", "true");
1636
+ current.appendChild(chevron);
1637
+ }
1638
+ root.appendChild(current);
1639
+
1640
+ if (!canPick) return root;
1641
+
1642
+ var menu = null;
1643
+ function closeMenu() {
1644
+ if (menu) {
1645
+ menu.remove();
1646
+ menu = null;
1647
+ document.removeEventListener("pointerdown", onOutside);
1648
+ }
1649
+ }
1650
+ function onOutside(e) {
1651
+ if (!root.contains(e.target)) closeMenu();
1652
+ }
1653
+ current.addEventListener("click", function () {
1654
+ if (menu) {
1655
+ closeMenu();
1656
+ return;
1657
+ }
1658
+ menu = el("div", "plan-storyboard-layout-menu");
1659
+ menu.setAttribute("role", "listbox");
1660
+ options.forEach(function (option) {
1661
+ var btn = document.createElement("button");
1662
+ btn.type = "button";
1663
+ btn.className =
1664
+ option.componentName === value
1665
+ ? "plan-storyboard-layout-option plan-storyboard-layout-option--selected"
1666
+ : "plan-storyboard-layout-option";
1667
+ btn.setAttribute("role", "option");
1668
+ btn.setAttribute("aria-selected", option.componentName === value ? "true" : "false");
1669
+ if (option.previewUrl) {
1670
+ var t = document.createElement("img");
1671
+ t.className = "plan-storyboard-layout-thumb plan-storyboard-layout-thumb--option";
1672
+ t.src = option.previewUrl;
1673
+ t.alt = "";
1674
+ t.setAttribute("aria-hidden", "true");
1675
+ btn.appendChild(t);
1676
+ }
1677
+ var text = el("span", "plan-storyboard-layout-option-text");
1678
+ var n = el("span", "plan-storyboard-layout-name");
1679
+ n.textContent = option.componentName;
1680
+ text.appendChild(n);
1681
+ if (option.purpose) {
1682
+ var p = el("span", "plan-storyboard-layout-purpose");
1683
+ p.textContent = option.purpose;
1684
+ text.appendChild(p);
1685
+ }
1686
+ var c = el("span", "plan-storyboard-layout-canvas");
1687
+ c.textContent = contentAreaSummary(option);
1688
+ text.appendChild(c);
1689
+ btn.appendChild(text);
1690
+ btn.addEventListener("click", function () {
1691
+ closeMenu();
1692
+ if (option.componentName === value) return;
1693
+ opts.onPick(option.componentName);
1694
+ });
1695
+ menu.appendChild(btn);
1696
+ });
1697
+ root.appendChild(menu);
1698
+ document.addEventListener("pointerdown", onOutside);
1699
+ });
1700
+ return root;
1701
+ }
1702
+
1703
+ // Deck-level layout knob: the one layout every board shares (or "Mixed"); a
1704
+ // pick binds EVERY storyboard's `layout` in one commit.
1705
+ function renderDeckLayoutKnob(storyboardData) {
1706
+ var wrap = el("div", "plan-storyline-deck-layout");
1707
+ wrap.appendChild(el("span", "plan-storyline-deck-layout-label", "Deck layout"));
1708
+ var seen = {};
1709
+ var distinct = 0;
1710
+ for (var i = 0; i < storyboardData.length; i++) {
1711
+ var name = storyboardData[i].layout || "";
1712
+ if (!seen[name]) {
1713
+ seen[name] = true;
1714
+ distinct++;
1715
+ }
1716
+ }
1717
+ var common = distinct === 1 && storyboardData[0] ? storyboardData[0].layout : undefined;
1718
+ wrap.appendChild(
1719
+ renderLayoutPicker(common || undefined, {
1720
+ placeholder: distinct > 1 ? "Mixed" : undefined,
1721
+ ariaLabel: "Set one layout for every slide",
1722
+ onPick: function (componentName) {
1723
+ commitPlan(PLAN.setStoryboardLayoutForAll(S.value, componentName));
1724
+ },
1725
+ }),
1726
+ );
1727
+ return wrap;
1728
+ }
1729
+
1511
1730
  function renderDetails(data, segment) {
1512
1731
  var aside = el("aside", "plan-storyboard-detail");
1732
+ aside.appendChild(el("div", "plan-storyboard-detail-label", "Layout"));
1733
+ aside.appendChild(
1734
+ renderLayoutPicker(data.layout, {
1735
+ onPick: function (componentName) {
1736
+ var d = {};
1737
+ for (var k in data) d[k] = data[k];
1738
+ d.layout = componentName;
1739
+ commitStoryboard(segment, d);
1740
+ },
1741
+ }),
1742
+ );
1513
1743
  aside.appendChild(el("div", "plan-storyboard-detail-label", "Story beat"));
1514
1744
  aside.appendChild(
1515
1745
  renderDetailField(
@@ -1552,29 +1782,174 @@
1552
1782
  return aside;
1553
1783
  }
1554
1784
 
1785
+ // The planned layout, when the storyboard names one the bundle has: its
1786
+ // chrome render grounds the frame and its canvas rect draws the content/
1787
+ // no-go zones. Absent both, the generic hatched bands render as before.
1788
+ function selectedPlanLayout(data) {
1789
+ var options = Array.isArray(CFG.layouts) ? CFG.layouts : [];
1790
+ if (!data.layout) return null;
1791
+ for (var i = 0; i < options.length; i++) {
1792
+ if (options[i].componentName === data.layout) return options[i];
1793
+ }
1794
+ return null;
1795
+ }
1796
+
1797
+ function pctOf(v) {
1798
+ return (v * 100).toFixed(2) + "%";
1799
+ }
1800
+
1801
+ function renderZoneOverlay(zone, areas) {
1802
+ var zones = el("div", "plan-storyboard-zones");
1803
+ zones.setAttribute("aria-hidden", "true");
1804
+ function wash(style) {
1805
+ var d = el("div", "plan-storyboard-zone-wash");
1806
+ for (var k in style) d.style[k] = style[k];
1807
+ zones.appendChild(d);
1808
+ }
1809
+ wash({ left: "0", top: "0", right: "0", height: pctOf(zone.y) });
1810
+ wash({ left: "0", top: pctOf(zone.y + zone.h), right: "0", bottom: "0" });
1811
+ wash({ left: "0", top: pctOf(zone.y), width: pctOf(zone.x), height: pctOf(zone.h) });
1812
+ wash({
1813
+ left: pctOf(zone.x + zone.w),
1814
+ top: pctOf(zone.y),
1815
+ right: "0",
1816
+ height: pctOf(zone.h),
1817
+ });
1818
+ // One rectangle per writable area; the washes above cover the union bbox
1819
+ // and the gutters between areas stay unmarked on purpose.
1820
+ for (var i = 0; i < areas.length; i++) {
1821
+ var area = areas[i];
1822
+ var rect = el("div", "plan-storyboard-zone-canvas");
1823
+ rect.style.left = pctOf(area.x);
1824
+ rect.style.top = pctOf(area.y);
1825
+ rect.style.width = pctOf(area.w);
1826
+ rect.style.height = pctOf(area.h);
1827
+ var chip = el("span", "plan-storyboard-zone-label");
1828
+ chip.textContent = areas.length > 1 ? "Content area " + (i + 1) : "Content area";
1829
+ rect.appendChild(chip);
1830
+ zones.appendChild(rect);
1831
+ }
1832
+ return zones;
1833
+ }
1834
+
1835
+ // Per-image-slot prompt fields drawn INSIDE the bound layout's image
1836
+ // placeholders. The value lives structurally in `imagePrompts` (keyed by the
1837
+ // slot's codegen prop name); a populated prompt is binding guidance for the
1838
+ // downstream image search/generation, empty = the AI decides. Own layer
1839
+ // after the blocks (paint order = DOM order), click-through except the fields.
1840
+ function renderImagePrompts(segment, data, slots) {
1841
+ var layer = el("div", "plan-storyboard-image-layer");
1842
+ slots.forEach(function (slot) {
1843
+ var value = (data.imagePrompts && data.imagePrompts[slot.key]) || "";
1844
+ var box = el("div", "plan-storyboard-image-slot" + (value.trim() ? " plan-storyboard-image-slot--filled" : ""));
1845
+ box.style.left = pctOf(slot.x);
1846
+ box.style.top = pctOf(slot.y);
1847
+ box.style.width = pctOf(slot.w);
1848
+ box.style.height = pctOf(slot.h);
1849
+ box.appendChild(el("span", "plan-storyboard-image-caption", "Image idea"));
1850
+ var input = document.createElement("textarea");
1851
+ input.className = "plan-storyboard-image-input";
1852
+ input.value = value;
1853
+ input.maxLength = PLAN.MAX_IMAGE_PROMPT_LENGTH;
1854
+ input.placeholder = "Describe the image for this slot — leave empty to let the AI decide";
1855
+ input.disabled = locked();
1856
+ input.setAttribute("aria-label", "Image idea " + slot.key);
1857
+ input.addEventListener("blur", function () {
1858
+ var trimmed = input.value.trim().slice(0, PLAN.MAX_IMAGE_PROMPT_LENGTH);
1859
+ if (trimmed === value.trim()) return;
1860
+ var next = {};
1861
+ for (var k in data.imagePrompts || {}) next[k] = data.imagePrompts[k];
1862
+ if (trimmed) next[slot.key] = trimmed;
1863
+ else delete next[slot.key];
1864
+ var rest = {};
1865
+ for (var k2 in data) if (k2 !== "imagePrompts") rest[k2] = data[k2];
1866
+ if (Object.keys(next).length) rest.imagePrompts = next;
1867
+ commitStoryboard(segment, rest);
1868
+ });
1869
+ box.appendChild(input);
1870
+ layer.appendChild(box);
1871
+ });
1872
+ return layer;
1873
+ }
1874
+
1555
1875
  function renderCanvas(segment) {
1556
1876
  var data = segment.data;
1557
1877
  var figure = el("figure", "plan-storyboard");
1878
+ var selected = selectedPlanLayout(data);
1879
+ var chromeUrl = selected && selected.previewUrl ? selected.previewUrl : null;
1880
+ var zone = selected && selected.canvasBounds ? selected.canvasBounds : null;
1881
+ var areas = selected && selected.contentAreas && selected.contentAreas.length ? selected.contentAreas : zone ? [zone] : [];
1882
+ // A known layout WITHOUT a canvas has no plannable region — wash the whole
1883
+ // frame + notice instead of presenting the generic guides as its zone.
1884
+ var noCanvas = Boolean(selected) && !zone;
1885
+ var showBands = !selected && !chromeUrl;
1886
+
1887
+ var addBtn = null;
1888
+ if (!locked()) {
1889
+ addBtn = el("button", "plan-storyboard-add", ICONS.plus + "<span>Add container</span>");
1890
+ addBtn.type = "button";
1891
+ addBtn.title = "Add container";
1892
+ addBtn.setAttribute("aria-label", "Add container");
1893
+ addBtn.disabled = data.blocks.length >= PLAN.MAX_BLOCKS;
1894
+ }
1558
1895
 
1559
1896
  var header = el("div", "plan-storyboard-header");
1560
1897
  header.appendChild(el("span", null, "Slide " + data.slide + " body storyboard"));
1561
1898
  var legend = el("span", "plan-storyboard-legend");
1562
1899
  legend.appendChild(el("span", "plan-storyboard-legend-item plan-storyboard-legend-item--element", "Design element"));
1563
1900
  legend.appendChild(el("span", "plan-storyboard-legend-item plan-storyboard-legend-item--content", "Content"));
1901
+ if (!showBands && addBtn) legend.appendChild(addBtn);
1564
1902
  header.appendChild(legend);
1565
1903
  figure.appendChild(header);
1566
1904
 
1567
1905
  var layout = el("div", "plan-storyboard-layout");
1568
- var frame = el("div", "plan-storyboard-frame slide-emboss");
1906
+ var frame = el(
1907
+ "div",
1908
+ chromeUrl
1909
+ ? "plan-storyboard-frame plan-storyboard-frame--chrome slide-emboss"
1910
+ : "plan-storyboard-frame slide-emboss",
1911
+ );
1912
+ if (chromeUrl) {
1913
+ var chromeImg = document.createElement("img");
1914
+ chromeImg.className = "plan-storyboard-chrome";
1915
+ chromeImg.src = chromeUrl;
1916
+ chromeImg.alt = "";
1917
+ chromeImg.setAttribute("aria-hidden", "true");
1918
+ frame.appendChild(chromeImg);
1919
+ }
1920
+ // Abstract guides grid ONLY without a template: once the real layout
1921
+ // render is the ground, its own artwork is the guide.
1922
+ if (showBands) {
1923
+ var grid = el("div", "plan-storyboard-body");
1924
+ grid.setAttribute("aria-hidden", "true");
1925
+ frame.appendChild(grid);
1926
+ }
1927
+ // Placeholder outlines go UNDER the washes: template ground, muted by the
1928
+ // no-go wash instead of competing with the sketch as another line system.
1929
+ if (selected && Array.isArray(selected.placeholders) && selected.placeholders.length) {
1930
+ var phLayer = el("div", "plan-storyboard-zones");
1931
+ phLayer.setAttribute("aria-hidden", "true");
1932
+ selected.placeholders.forEach(function (ph) {
1933
+ var box = el("div", "plan-storyboard-ph");
1934
+ box.style.left = pctOf(ph.x);
1935
+ box.style.top = pctOf(ph.y);
1936
+ box.style.width = pctOf(ph.w);
1937
+ box.style.height = pctOf(ph.h);
1938
+ if (ph.label) {
1939
+ var chip = el("span", "plan-storyboard-ph-label");
1940
+ chip.textContent = ph.label;
1941
+ box.appendChild(chip);
1942
+ }
1943
+ phLayer.appendChild(box);
1944
+ });
1945
+ frame.appendChild(phLayer);
1946
+ }
1947
+ if (zone) frame.appendChild(renderZoneOverlay(zone, areas));
1569
1948
 
1570
1949
  var top = el("div", "plan-storyboard-reserved plan-storyboard-reserved-top");
1571
1950
  top.appendChild(document.createTextNode("Template header/title area not planned here"));
1572
- if (!locked()) {
1573
- var add = el("button", "plan-storyboard-add", ICONS.plus + "<span>Add container</span>");
1574
- add.type = "button";
1575
- add.title = "Add container";
1576
- add.setAttribute("aria-label", "Add container");
1577
- add.disabled = data.blocks.length >= PLAN.MAX_BLOCKS;
1951
+ if (addBtn) {
1952
+ var add = addBtn;
1578
1953
  add.addEventListener("click", function () {
1579
1954
  if (data.blocks.length >= PLAN.MAX_BLOCKS) return;
1580
1955
  var serial = data.blocks.length + 1;
@@ -1601,21 +1976,34 @@
1601
1976
  ]);
1602
1977
  commitStoryboard(segment, withBlocks(data, nextBlocks));
1603
1978
  });
1604
- top.appendChild(add);
1979
+ if (showBands) top.appendChild(add);
1605
1980
  }
1606
- frame.appendChild(top);
1981
+ if (showBands) frame.appendChild(top);
1607
1982
 
1608
- var grid = el("div", "plan-storyboard-body");
1609
- grid.setAttribute("aria-hidden", "true");
1610
- frame.appendChild(grid);
1611
1983
 
1612
1984
  var canRemove = !locked() && data.blocks.length > 1;
1613
1985
  data.blocks.forEach(function (block) {
1614
1986
  frame.appendChild(renderBlock(frame, block, data, segment, canRemove));
1615
1987
  });
1616
1988
 
1617
- var bottom = el("div", "plan-storyboard-reserved plan-storyboard-reserved-bottom", "Footer/template area not planned here");
1618
- frame.appendChild(bottom);
1989
+ if (showBands) {
1990
+ var bottom = el("div", "plan-storyboard-reserved plan-storyboard-reserved-bottom", "Footer/template area not planned here");
1991
+ frame.appendChild(bottom);
1992
+ }
1993
+ var imageSlots = selected && Array.isArray(selected.placeholders)
1994
+ ? selected.placeholders.filter(function (ph) { return typeof ph.key === "string"; })
1995
+ : [];
1996
+ if (imageSlots.length) frame.appendChild(renderImagePrompts(segment, data, imageSlots));
1997
+ if (noCanvas) {
1998
+ // Own top layer, click-through: the warning must never hide behind blocks.
1999
+ var noticeLayer = el("div", "plan-storyboard-zone-notice-layer");
2000
+ noticeLayer.setAttribute("aria-hidden", "true");
2001
+ var notice = el("span", "plan-storyboard-zone-notice");
2002
+ notice.textContent =
2003
+ "No content area on this layout — pick a layout with a content body";
2004
+ noticeLayer.appendChild(notice);
2005
+ frame.appendChild(noticeLayer);
2006
+ }
1619
2007
 
1620
2008
  layout.appendChild(frame);
1621
2009
  layout.appendChild(renderDetails(data, segment));
@@ -1662,7 +2050,7 @@
1662
2050
  slideHeading: a.slideHeading,
1663
2051
  text: text,
1664
2052
  at: new Date().toISOString(),
1665
- status: "queued",
2053
+ status: "sent",
1666
2054
  };
1667
2055
  }
1668
2056
  function anchorLabel(c) {
@@ -1674,7 +2062,7 @@
1674
2062
  }
1675
2063
  // The rail: every comment of this session, newest last, with its anchor and
1676
2064
  // where it stands. This is what replaced the single "previous round" note —
1677
- // it survives rounds and shows the queue the reviewer built up.
2065
+ // it survives rounds and shows every comment the reviewer sent.
1678
2066
  function ledgerRail() {
1679
2067
  var rail = el("div", "sm-ledger");
1680
2068
  ledger.forEach(function (c) {
@@ -1707,9 +2095,8 @@
1707
2095
  note.innerHTML =
1708
2096
  "<b>" +
1709
2097
  (workingNote || "Comments sent.") +
1710
- "</b> The agent is revising the plan — this tab updates itself when the " +
1711
- "next round lands. Keep reading and navigating; anything you send now is " +
1712
- "queued and delivered with that round.";
2098
+ "</b> The agent is revising the plan — this tab refreshes when the next " +
2099
+ "round is ready. Keep reading and navigating; comment again on that round.";
1713
2100
  status.appendChild(note);
1714
2101
  composerEl.appendChild(status);
1715
2102
  // The permission prompt was raised at the send click (a user gesture, the
@@ -1771,8 +2158,9 @@
1771
2158
  commentEl = el("textarea", "sm-chat-ta");
1772
2159
  commentEl.rows = 1;
1773
2160
  commentEl.placeholder = working
1774
- ? "Keep commenting — this goes to the agent with the next round."
2161
+ ? "The agent is revising — comment on the next round."
1775
2162
  : "What should change? Leave a note, or edit the plan directly.";
2163
+ commentEl.disabled = working;
1776
2164
  commentEl.value = feedbackText;
1777
2165
  commentEl.addEventListener("input", function () {
1778
2166
  feedbackText = commentEl.value;
@@ -1795,8 +2183,9 @@
1795
2183
  } else {
1796
2184
  sendBtn = el("button", "sm-send", ICONS.send);
1797
2185
  sendBtn.type = "button";
1798
- sendBtn.title = working ? "Queue for the next round" : "Send feedback";
2186
+ sendBtn.title = working ? "The agent is revising — send on the next round" : "Send feedback";
1799
2187
  sendBtn.setAttribute("aria-label", sendBtn.title);
2188
+ sendBtn.disabled = working;
1800
2189
  }
1801
2190
  sendBtn.onclick = requestChanges;
1802
2191
  row.appendChild(sendBtn);
@@ -1811,7 +2200,7 @@
1811
2200
  "<span>Approve plan</span></span>" +
1812
2201
  '<span class="sub">the agent builds the slides next</span>';
1813
2202
  approveBtn.onclick = function () {
1814
- if (working || queuedComments().length) return; // superseded round / unsent queue
2203
+ if (working) return; // superseded round
1815
2204
  if (S.storyDraft) return; // blocked while a storyline draft is staged
1816
2205
  // Approving with unsent feedback in the input discards it — confirm inline.
1817
2206
  if (commentText() && !approveArmed) {
@@ -1828,35 +2217,28 @@
1828
2217
  }
1829
2218
 
1830
2219
  // ---------- working mode (the agent is revising) ----------
1831
- // The tab neither veils over nor freezes: the plan stays readable, the strip
1832
- // stays navigable, the composer stays live (comments queue), and only the
1833
- // editing affordances + Approve go away. Heartbeat polling waits for the
1834
- // agent's next `slidemind plan` round, which reloads this same tab.
2220
+ // The tab neither veils over nor freezes: the plan stays readable and the
2221
+ // strip stays navigable; the composer, the editing affordances and Approve go
2222
+ // away until the next round. Heartbeat polling waits for the agent's next
2223
+ // `slidemind plan` round, which reloads this same tab.
1835
2224
  function enterWorking(note) {
1836
2225
  working = true;
1837
2226
  workingNote = note || null;
1838
2227
  if (wsEl) wsEl.classList.add("is-waiting");
1839
2228
  document.title = WAITING_TITLE; // the tab label signals the state from the tab bar
2229
+ saveCtx(); // a plain refresh of this round stays in working mode
1840
2230
  renderSurface(); // rebuild the surface read-only (no contenteditable attrs)
1841
2231
  renderComposer();
1842
2232
  startNextRoundPolling();
1843
2233
  }
1844
2234
 
1845
2235
  // ---------- submit ----------
1846
- // Send. WORKING → the comment is queued locally (the agent is mid-round; it
1847
- // rides along on the next run). PARKED → it resolves the gate right now,
1848
- // carrying any queued stragglers from a flush that couldn't reach the server.
2236
+ // Send: resolves the gate right now with the typed comment (plus a staged
2237
+ // storyline restructure as a whole-story instruction). Off while WORKING.
1849
2238
  function requestChanges() {
2239
+ if (working) return;
1850
2240
  var typed = commentText();
1851
- if (working) {
1852
- if (!typed) return;
1853
- ledger.push(newComment(typed));
1854
- saveLedger();
1855
- clearDraft();
1856
- renderComposer();
1857
- return;
1858
- }
1859
- var batch = queuedComments();
2241
+ var batch = [];
1860
2242
  if (S.storyDraft) {
1861
2243
  var staged = PLAN.stageStoryIteration(S.value, S.storyDraft);
1862
2244
  if (!staged) return;
@@ -1896,23 +2278,14 @@
1896
2278
  })
1897
2279
  .then(function (res) {
1898
2280
  if (res.status === 409) {
1899
- // The gate already drained a backlog this run — this round is stale.
1900
- // Park the batch in the ledger as QUEUED (the next claim flushes it),
1901
- // clear the input so it isn't typed twice, and disarm the submit
1902
- // latch — leaving it armed made every later send a silent no-op.
1903
- // A staged story restructure survives only as its instruction TEXT:
1904
- // its editedPlanMarkdown was computed against the superseded plan and
1905
- // applying it to the next round would corrupt it, so it is dropped
1906
- // deliberately (same call the add-in dialog makes on round arrival).
1907
- if (decision === "comments") {
1908
- pendingComments.forEach(function (c) {
1909
- c.status = "queued";
1910
- if (ledger.indexOf(c) < 0) ledger.push(c);
1911
- });
1912
- saveLedger();
1913
- clearDraft();
1914
- S.storyDraft = null;
1915
- }
2281
+ // Another tab already decided this run — this round is stale. The
2282
+ // typed text stays in the input (restored on the next round's reload)
2283
+ // so it isn't lost; disarm the submit latch — leaving it armed made
2284
+ // every later send a silent no-op. A staged story restructure is
2285
+ // dropped deliberately: its editedPlanMarkdown was computed against
2286
+ // the superseded plan and applying it to the next round would corrupt
2287
+ // it (same call the add-in dialog makes on round arrival).
2288
+ S.storyDraft = null;
1916
2289
  submitted = false;
1917
2290
  enterWorking("Round superseded.");
1918
2291
  return;
@@ -1949,14 +2322,16 @@
1949
2322
  }, 120);
1950
2323
  }
1951
2324
  // The shared heartbeat poll (gate-session.js) with this gate's two endings:
1952
- // a retired tab hands its backlog over FIRST — the fresh window that replaces
1953
- // it starts with an empty sessionStorage, so anything still queued here would
1954
- // otherwise be lost (the handover also drains the new run immediately).
2325
+ // a retired tab simply says where the review went.
1955
2326
  function startNextRoundPolling() {
1956
2327
  SES.startNextRoundPolling({
1957
2328
  onEnded: showSessionEnded,
2329
+ // A retired tab still holding an unsent draft must not discard it: the
2330
+ // fresh window that replaced it has its own (empty) sessionStorage, so
2331
+ // this tab reloads into the new round instead and flags itself.
1958
2332
  onSuperseded: function () {
1959
- flushBacklog().then(showSuperseded, showSuperseded);
2333
+ if (commentText()) SES.markRoundReadyAndReload();
2334
+ else showSuperseded();
1960
2335
  },
1961
2336
  });
1962
2337
  }
@@ -2028,8 +2403,8 @@
2028
2403
  var stale = document.getElementById("plan-root");
2029
2404
  if (stale && stale.parentNode) stale.parentNode.removeChild(stale);
2030
2405
 
2031
- // Session first: it decides the MODE this page renders in (a queued backlog
2032
- // means the agent is still working) and the round-transition state.
2406
+ // Session first: it decides the MODE this page renders in (a refresh of an
2407
+ // already-sent round stays in working mode) and the round-transition state.
2033
2408
  var flagged = SES.consumeRoundFlag();
2034
2409
  restoreSession();
2035
2410
 
@@ -2038,14 +2413,10 @@
2038
2413
  buildWorkspace();
2039
2414
 
2040
2415
  if (working) {
2041
- // Drain round: hand the backlog over, stay in working mode, stay silent —
2042
- // the reviewer already knows they're mid-round.
2416
+ // A refresh of a round the reviewer already sent: keep waiting quietly.
2043
2417
  if (wsEl) wsEl.classList.add("is-waiting");
2044
2418
  document.title = WAITING_TITLE;
2045
2419
  startNextRoundPolling();
2046
- flushBacklog().then(function () {
2047
- renderComposer(); // rows flip queued → sent
2048
- });
2049
2420
  } else {
2050
2421
  // Parked round: the agent is waiting on the reviewer — announce it.
2051
2422
  SES.announceNewRound(flagged);