LF Scripts Board Doc, 30/07/2026 update
What this page is for. The full reference for /script-board (the LF Scripts Board): why it exists, how it works, and where it sits in the content system. Read by Claude at the start of any session that touches this page. The WHY sections at the top are not decoration: they are the design constraints every future change must respect.
Table of contents
1. The problem this tool solves
AL writes a YouTube long-form script every day, continuously. An idea comes, he notes it. All week, notes aggregate. At the last moment he reorganizes everything, turns the camera on, and films. That is the real workflow, and it collides with a tension he has carried through every previous scripting method:
- Too much structure kills the natural delivery. A fully written, linear script reads like a script. The on-camera energy dies.
- Too much improvisation kills the value. Pure improv loses the shifts, the systems, the density that makes a video worth watching.
- Ideas arrive in a continuous stream all week, not in one writing session. The capture surface has to be open all the time and take a half-formed thought in five seconds.
- Paper is free but unreadable and outside the system. A sheet of paper gives total spatial freedom, but it cannot be searched, synced, backed up, or linked to the planner, and it becomes illegible as it fills.
- Notion is traceable but linear and rigid. A Notion page keeps everything, but it forces a top-to-bottom document shape onto thinking that is not linear yet. Reorganizing blocks in a long page is friction, not flow.
The board resolves the tension: capture stays loose and continuous (improv survives), while the last-moment spatial reorganization produces just enough structure to protect the value, without ever producing a linear script to read from.
2. The answer: a spatial canvas per script
In AL's words, the board is "a mix of Miro, Notion, and a piece of paper". One board per video. Two zones:
- A fixed left sidebar: the recurring script template (TT, TB, Hook, S1/S2/S3, Checks, plus custom fields). Same shape on every board, so the recurring decisions always have a home.
- A freeform canvas: text cells and images placed, sized, colored, and moved anywhere. This is where the week of thinking lands, one note at a time, and where the final reorganization happens the day of the shoot.
Design consequences that follow from part 1 and must not be regressed:
- Adding a note must cost near zero (add cell, type, done; no mandatory fields, no forced position).
- Reorganizing must be continuous and cheap (drag anything anywhere at any time; multi-select; zoom out to see the whole shape).
- Nothing may ever be lost (a lost board is a week of thinking lost; see the persistence stack in 3.2).
- The board must scale to the 200th piece of content: boards are cheap to create, cheap to archive, and the list stays readable (Active runs sorted least-ready first).
3. How it works technically
3.1 Data model
One JSON object per board, stored as a single opaque blob (see 3.2). Shape:
{
title: string, // human label, NOT the URL id (see 3.3)
sidebar: {
tt: string, ttHistory: [{text,t}], ttAlts: [{id,text}], // title + history (30) + alternates (5)
tb: { url, key }, tbHistory: [{url,key,t}], // thumbnail (R2) + history (20)
hook: string,
s: [ { text, pct } x3 ], // S1 Storytelling, S2 Shifts, S3 Systems
checks: [ { id, text, checked } ], // LEGACY per-board list, migration source only (3.1.1)
checksDone: { globalItemId: true }, // per-board checked state over the GLOBAL checks list
videoDone: { field: bool x7 }, // per-field "shot in video" checkboxes, drives the Filmed bar
manualPct: 0-50, // AL's manual judgment half of the Ready bar, steps of 10
custom: [ { id, label, text } ] // ad-hoc extra sidebar fields, max 20
},
elements: [ // the freeform canvas
{ id, type: 'text', x, y, w, h, title, body, color, fontSize, align, border, done },
{ id, type: 'image', x, y, w, h, url, key },
{ id, type: 'mirror', x, y, w, h, field }
],
arrows: [ { id, from, to, color } ], // canvas connectors, endpoints = element ids (3.7)
dismissedMirrors: [ 'tt', ... ], // mirrors deliberately removed from the canvas
notion: { pageId, url, title, status } // planner interconnection (3.4), empty pageId = not linked
}
S1 / S2 / S3 semantics (AL, 30/07/2026). AL's three personal script variables: S1 = Storytelling, S2 = Shifts, S3 = Systems. Each video weights them differently and one usually dominates. The three percentages can never exceed 100 in total: the slider being moved is capped at whatever the other two leave available (enforced on interaction only; a legacy board already past 100 is left untouched until a slider next moves). The dominant percentage renders gold everywhere it appears (sidebar and canvas combo box), the others muted; all three equal means all three gold.
3.1.1 The GLOBAL checks list
Since 30/07/2026 the check items (text and order) are global across all boards: one shared list at its own D1 key, script_board_checks ([{id, text}], plain read-modify-write like the registry). Same mental model as LFCPCP's process template: edit an item on any board and it changes on every board, including future ones. Only the checked state is per-board (sidebar.checksDone, keyed by global item id).
Why this changed: the "disappearing checks" episode. AL typed check items on one board (V9), later opened another (V8) and found them "gone", twice. They were never lost: the list was per-board by design, so each board started empty. That design contradicted AL's mental model (the sidebar inputs apply to all videos), so the list went global. A guard ships with it: an empty server value can never clobber a non-empty in-memory list (protects the one-time migration seed against a fetch race). Migration: the first board opened that still carries a legacy per-board sidebar.checks list seeds the global list, and its checked flags become that board's checksDone. CTAs and Legit were separate checklists until 30/07/2026; sbNormalizeState() still folds old ctas/legit arrays in so nothing typed there is lost.
3.2 Persistence: D1, anti-clobber guard, per-save history
Three layers, so that "a lost script = a week of thinking lost" cannot happen:
- D1 cross-device sync. Each board is one row in
intranet_state(keyscript_board_<boardId>), written through the shared endpoint/api/admin/state/[key].sbSyncedStore()is the same last-write-wins algorithm as/alsboard'sbpSyncedStore(payload{v, t}, client-ms timestamp, 10s poll skipped while typing or dragging, 400ms network debounce, richer-tie escape hatch att=0), kept as an independent copy so a change to one page can never break the other. localStorage doubles as an instant-paint cache per board. - X-If-Updated-At anti-clobber guard (added 30/07/2026). The same optimistic-concurrency pattern as /backdata/lf-cross-platform-content: every GET memorizes the row's
updated_at, every PUT sends it back asX-If-Updated-At, and the server only writes if the row has not moved (atomic conditional UPDATE, 409 otherwise). On 409 the store refetches, adopts the remote if it wins ont, then retries once with the fresh token. Net effect: a stale tab can no longer blind-overwrite a newer save it never saw. Before this, LWW alone left a window (a tab that had not polled yet could clobber another device's newer write). - Per-save history (added 30/07/2026). Every PUT on a
script_board_*key (boards, registry, global checks) is also versioned intointranet_state_history, cap 200 versions per key (the endpoint's existing audit-F7 mechanism, extended by prefix). Any future loss incident becomes a one-minute restore from history instead of a monthly R2 dump being the only recovery floor.
3.3 Multi-boards: registry, template, create, rename, archive
- URL id:
?video=<slug>(fallback?board=, defaultdefault), sanitized to[a-z0-9_-]. This is the D1 key suffix. One board = one script = one video. - Title:
state.title, typed in the switch-bar input, auto-saves on change (no Rename button). Shown in the dropdown, the header, and Active runs; falls back to the raw id. - Registry:
script_board_registry,[{id, title}], read-modify-write. Powers the dropdown and Active runs. A board's own boot upserts its registry entry. - Template board: the board with id
template. Nothing special in its data path; + New run copies itselementsas the starting canvas of a new board. Sidebar values are never templated (every new board starts empty by design). - + New run (Active runs header): custom modal asks a title, slugifies it, copies the Template canvas, PUTs the fresh board, upserts the registry, switches to it.
- Archive (soft delete, added 30/07/2026): the Archive button (hidden on
defaultandtemplate) removes the board from the registry only, after a confirm modal. Its D1 row and history are untouched. Reopening/script-board?video=<id>re-registers it automatically (boot upsert), which is also the un-archive path. Nothing on this page ever deletes board data. - Switching is in-place (no page reload):
sbSwitchBoard()destroys the old store, recomputes the keys, paints the cached state, updates the URL viapushState, boots a fresh store. Back/forward handled viapopstate.
3.4 Notion planner interconnection (v1, no new Notion column)
The YouTube Long Form Content Planner database stays exactly as it is (no added columns). The interconnection is deliberately thin:
| Direction | Mechanism |
|---|---|
| Board to Notion | The Planner button (switch bar) opens a selector listing the planner rows (id, video number, title, status), fed by GET /api/scriptboard/planner-rows. Picking a row stores state.notion = {pageId, url, title, status} on the board. The linked row's title then shows in the switch bar as a link that opens the Notion row, next to its status chip. |
| Notion to board | Linking fires one idempotent write-back via POST /api/scriptboard/link-row: a paragraph block "Script board: <board title>" (the title linking to /script-board?video=<id>) inserted at the head of the row's page body. The function scans the first 100 blocks first and never duplicates the block for the same board URL. Notion API limitation: exact head position is only possible on an empty page; otherwise the block lands right after the first block. |
| Status display | The board shows the row's title and status read at load time (a small "status read at load time" note sits next to it). Rows are fetched once per page session, refreshed on each board load, never synced in realtime. Unlink is available in the same Planner selector. |
Both functions live outside the /api/admin/ middleware prefix, so they replicate the exact same admin gate internally (getAuthenticatedEmail + env.ADMIN_EMAIL allowlist, fail-closed), on top of the zone-wide Cloudflare Access gate. Secret: env.NOTION_API_KEY, with fallback to the older env.NOTION_TOKEN already used by /api/notion/planner.
3.5 The mirror system (bidirectional sidebar and canvas)
A mirror is a canvas element (type: 'mirror') that renders and edits the exact same underlying sidebar.<field> data as the fixed sidebar, not a copy. MIRROR_FIELDS = tt, tb, hook, sx, checks. sx is the combined S1/S2/S3 box: three columns, each label plus its percentage with dominance coloring, no textbox or slider (editing stays in the sidebar). One shared render + bind function pair per field kind is called identically for the sidebar mount (bound once) and any mirror mount (bound on each canvas render); a bug fix in one of those functions fixes the sidebar and every mirror at once.
Auto-seeding and dismissal. sbEnsureMirrors() runs on every normalize and adds exactly one mirror per missing field, skipping anything in dismissedMirrors. Deleting a mirror only removes that view (the field stays editable in the sidebar) and it never resurrects. Live-sync rule: every field mutation funnels through sbAfterFieldSave() (save, re-render sidebar, re-render canvas unless focus is inside it), so edits propagate both ways within ~300ms and an actively-edited element never gets its DOM ripped out from under the cursor.
3.6 Progress: Ready and Filmed
| Bar | Question it answers | Formula |
|---|---|---|
| Ready | Is the script written and good enough to shoot? | Auto half (0-50): 7 booleans (tt/tb/hook/s1/s2/s3 non-empty + ALL global checks ticked on this board). Manual half (0-50): sidebar.manualPct, AL's own judgment set with a minus/plus stepper in steps of 10. Field presence cannot see script quality; the manual half exists for that. |
| Filmed | How much has actually been shot? | 7 per-field "shot in video" checkboxes (sidebar.videoDone), checked/7. Shown in the sidebar label rows and on mirrors, both writing the same object. |
Active runs lists every registered board with both bars read-only, sorted least-Ready first; only the currently open board's dot pulses green, toggled instantly on switch. The editable stepper and checkboxes live only on the open board itself.
3.7 Canvas interactions
- Add: "+ Add text cell", "+ Upload image" (client-side downscale over 2000px, R2 via
/api/admin/upload-image, box sized to the image's aspect ratio at creation, no letterboxing), drag-and-drop files onto the canvas, or right-click empty space (Add text cell / Upload image here / Zoom to fit / Select all). New elements auto-avoid overlap (sbFindFreeSpot); dropped images land exactly where dropped. - Edit: cells are contenteditable (title + body); right-click a cell for color (9 swatches), size (S/M/L), align (left/center/right), border (Box/Text/Both/None; Text wraps the body paragraph only, never the title), plus Mark done / Unmark and the arrow actions below.
- Arrows (connectors, added 30/07/2026): right-click an element, "Connect arrow to...", the cursor turns into a crosshair, click the target element to create the arrow (Escape or an empty-space click cancels). Thin straight line, gray
#9a9aa3by default, head on the target side, edge to edge. Drawn on an SVG layer that is the first child of the canvas, so it sits UNDER the cells and follows zoom and pan for free; drag and resize redraw it live. Right-click the arrow itself to recolor it (default gray plus gold, red, green, teal from the cell palette) or "Remove arrow"; an element's own menu also carries "Remove arrow(s)" for everything attached to it. Images and mirrors get a slim arrow-only right-click menu. Stored inarrowson the board (3.1), persisted and undoable like everything else; deleting an element deletes its arrows; "+ New run" copies the Template's arrows along with its canvas. - Mark done per cell (added 30/07/2026): the round that appears on hover at a text cell's top right, or "Mark done" in its right-click menu, toggles the cell's
doneflag instantly: the cell dims, its text strikes through, the round fills gold, the same bubble language as the sidebar Checks. Per-cell and persisted; no effect on the Ready or Filmed bars. - Move and select: drag by handle (cells) or whole body (images); click-drag empty space pans; shift+drag draws a marquee; dragging a member of a multi-selection moves the group; Delete/Backspace deletes the selection after a confirm modal; Escape or a plain empty-space click clears it.
- Zoom: trackpad pinch / ctrl+wheel and real two-finger touch pinch (0.25 to 2.5); all drag math divides by the zoom factor. "Zoom to fit" frames everything currently on the canvas. In fullscreen (added 30/07/2026) Cmd/Ctrl with +, - or = zooms the canvas in the same range instead of the Chrome page, and Cmd/Ctrl+0 runs Zoom to fit; outside fullscreen nothing is intercepted, the browser keeps its native page-zoom shortcuts.
- Fullscreen: native Fullscreen API on the board zone with a fake-fullscreen CSS fallback; the sidebar collapses into a bottom sheet behind a round "Inputs" FAB (the same DOM node physically moves, bindings travel with it). Since 30/07/2026 the FAB, the sheet, its backdrop and every floater/modal mount inside the fullscreen element (
sbOverlayRoot()): native fullscreen renders nothing outside the fullscreen subtree, which is exactly why the FAB used to be invisible there, and fake fullscreen would stack its z-index 9000 above body-level popups. Escape closes an open sheet first WITHOUT leaving fullscreen; in native fullscreen the page claims Esc through the Keyboard Lock API where available (Chrome desktop), elsewhere the browser exit also closes the sheet, nothing breaks. Exit button hidden in native fullscreen (Esc does it). Canvas height always reaches the viewport bottom (sbFitCanvasHeight). Spacebar never scrolls the page. - Undo: Cmd/Ctrl+Z, in-memory stack of 40 full-state snapshots pushed before each structural action (add/delete/move/resize/pick/toggle/upload, one per edit session for text fields). Cleared on board switch and on adopting a remote update. While typing, the browser's native text undo owns the shortcut.
- Title and thumbnail history:
ttHistory(30) andtbHistory(20) log the previous value on change; restoring swaps symmetrically so nothing is lost.ttAlts(max 5) holds deliberate candidate titles, distinct from history. TB has no alternates concept. - Custom sidebar fields:
sidebar.custom, max 20, plain text, added via "+ Add field". Deliberately excluded from mirrors, both progress bars, and video-done checkboxes. - Mobile: sidebar stacks above the canvas below 720px (site-wide breakpoint); fullscreen + FAB behave identically on mobile and desktop; touch pinch and pointer-based drag need no separate code path.
3.8 Files, endpoints, D1 keys
| What | Where |
|---|---|
| Page | public/script-board.html (single file, inline CSS/JS) |
| Doc (this page) | public/backdata/script-board-doc.html |
| State endpoint (shared with alsboard) | functions/api/admin/state/[key].js, D1 intranet_state + intranet_state_history |
| Board keys | script_board_<boardId>, script_board_registry, script_board_checks |
| Planner rows proxy | functions/api/scriptboard/planner-rows.js (GET, admin-gated, Notion DB 04428224657182ee8f3d81fc0fbe50b0) |
| Planner write-back | functions/api/scriptboard/link-row.js (POST, admin-gated, idempotent link block) |
| Image upload / serve | functions/api/admin/upload-image.js / functions/api/admin/board-image/[[path]].js, R2 bucket eo-user-toolkit |
| Homepage tile | public/index.html tile 08; doc tile 16 on /backdata |
| Smoke entries | scripts/smoke-test.sh: /script-board, doc, upload, serve, planner-rows (all 302 unauthenticated) |
3.9 Hard rules
- English-only UI copy (repo rule 9), and zero em-dashes anywhere in UI text or this doc.
- No horizontal page overflow:
html,body{overflow-x:hidden;max-width:100%}plusmin-width:0on the canvas flex column. public/alsboard.htmland its backend must never be touched by changes to this page; verify viagit diffbefore deploy.- Never serve live content from the
eo-backupsR2 bucket;eo-user-toolkitis the bucket for this feature. - Validate the inline
<script>withnode -e "new Function(...)"before every deploy. - Nothing on this page ever hard-deletes a board: Archive only unlists.
- Do not add friction to note capture: no mandatory fields on cells, no forced structure on the canvas (see part 1; this is the product constraint, not a style preference).
4. Where it sits in the system
Divide of responsibilities, agreed 30/07/2026:
| Surface | Role |
|---|---|
| LF Scripts Board | The real state of a script's progress. Everything that happens between "idea" and "camera on" lives here: continuous notes, the template fields, the reorganization, the Ready/Filmed truth. |
| Notion planner (YouTube Long Form Content Planner) | Archive and outputs base. Kept exactly as it is (no new columns, no restyle). Scheduling, statuses, and the durable record of what was posted live there. The two are linked per video (3.4) so each side is one click from the other. |
The day of the shoot: open the board, reorganize the canvas into the shape of the video, film from it (camera on, board on screen, no linear script). After the shoot, the recording goes through the classic circuit: Gling transcript, then the transcript feeds the downstream content pipeline (cross-platform derivatives, per the LF cross platform content doc). The board's Filmed bar tracks which template fields made it into the recording; the planner row's status records the video's lifecycle.
The 200th-piece test: the system must hold at content piece 200, not just piece 10. That is why boards are one D1 row each (cheap), archived boards disappear from the list without deleting data, every save is versioned (cap 200 per key), and the Active runs list stays a short operational view (registered boards only) rather than an archive.
5. Changelog
- 30/07/2026 (ninth pass) S1/S2/S3 now always sum to 100: moving one redistributes the other two proportionally (was capping-only), and the % text is directly editable (click, type, Enter). Right-click parity: mirrors and videos now get the same menu as text cells (Mark done, Connect arrow, Remove arrow), not a stripped-down arrow-only one, which also gave mirrors their missing "Mark done". Done-state checkmark glyph removed (color alone carries it) and pushed further from the delete "×" so the two can't be misclicked together; done/strikethrough now applies uniformly across text/mirror/image/video cells and actually persists across reload (was silently dropped for every type but text). Shift+click a cell toggles it into/out of a multi-selection one at a time (existing Shift+drag marquee-select on empty canvas is the batch equivalent). Checks field is now a pinned dock at the bottom of the board viewport, independent of scroll/zoom/pan, always checkable ("its own" small "×" to remove it from the board). Video upload (.mov/.mp4/.webm, up to 300MB) alongside images, played inline with native controls (drag handle only, so controls stay clickable); backend Range-request support added for scrubbing.
- 30/07/2026 (eighth pass) Arrows between canvas elements: right-click "Connect arrow to...", click the target, Escape cancels; thin gray SVG connectors under the cells, recolorable (gold/red/green/teal) or removable via right-click on the arrow or the element; stored in
arrows, undoable, template arrows copied by "+ New run" (3.7). Per-cell Mark done (hover round + right-click): dimmed cell, struck text, gold round, persisteddoneflag. Fullscreen fixed for real: the Inputs FAB, sheet and all floaters/modals now mount inside the fullscreen element so they render in native fullscreen (sbOverlayRoot()), and Escape closes the sheet without leaving fullscreen (Keyboard Lock where available). Cmd/Ctrl +, -, = and 0 zoom the canvas while fullscreen instead of triggering Chrome page zoom (0 = Zoom to fit). - 30/07/2026 (seventh pass, full review) Renamed to LF Scripts Board (title, header, homepage tile). Persistence hardened: X-If-Updated-At anti-clobber guard on the board store (3.2) + per-save history for all
script_board_*keys (cap 200/key). Archive (soft delete) added to the switch bar (3.3). Notion planner interconnection v1 (3.4): planner-rows proxy, Planner selector + linked-row display with load-time status, idempotent "Script board:" write-back block on the Notion row. Doc rewritten why-first (parts 1, 2, 4 added). Em-dashes purged from UI strings (upload errors, Template option). Doc tile 16 added on /backdata. - 30/07/2026 (sixth pass) Checks list went GLOBAL across boards, checked state per-board (3.1.1); round LFCPCP-style check bubbles; S1/S2/S3 semantics documented, sum capped at 100 on interaction, gold dominance; the three S mirrors merged into one
sxcombo; Ready checks criterion = all global items ticked; full-width layout, canvas reaches viewport bottom; Exit fullscreen hidden in native mode; anti-overlap placement. - 30/07/2026 (fifth pass) Ready bar split auto (0-50) + manual (0-50); Filmed bar on 7 video-done checkboxes synced sidebar/mirror; Active runs shows both bars, only the open board pulses; S mirrors percentage-only; border "text" mode wraps body only; click-to-select then Delete; Cmd/Ctrl+Z undo; TT/TB history + TT alternates; custom sidebar fields; canvas right-click menu; title auto-saves.
- 30/07/2026 (fourth pass) Right-click cell menu extended (size/align/border); in-place board switching, no reload; Zoom to fit; CTAs + Legit merged into Checks; Active runs restyled to the LFCPCP pattern with "+ New run"; labels stripped; S slider + percentage on one row; spacebar scroll suppressed. Standing rule: no verbose hint/caption UI text on any future tool.
- 30/07/2026 (third pass) Mirror system (3.5), checklists, custom Create modal, black-bar image fix, whole-image drag, pinch/wheel zoom, fullscreen + Inputs FAB, Active runs, first version of this doc.
- 30/07/2026 (second pass) Rebuilt from a fixed 4x2 grid into the freeform canvas; image upload (R2
eo-user-toolkit). - 30/07/2026 (first ship) Page created: fixed 8-cell grid cloned from
/alsboard's bp-cells pattern, tile 08 on the homepage.