From d87dbbe34e23f55b6aa928f5dc996f17ca165a27 Mon Sep 17 00:00:00 2001 From: Omar Eid Date: Sun, 12 Jul 2026 07:35:12 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(hero):=20center=20the=20pickets=20board?= =?UTF-8?q?=20(each=20title=20dead-center)=20=E2=80=94=20columns=20flip=20?= =?UTF-8?q?in=20place,=20never=20slide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous no-slide fix anchored every title to one journey-wide LEFT column, which LEFT-HUGGED short titles: WELCOME rendered jammed at the left edge with ~16 blank tiles trailing, clearly off-center. The right model: every board COLUMN is a static physical slot, and each title is CENTERED on its own width. As the scroll front crosses a slot, that slot card-flips between its current word's glyph and the upcoming/previous word's glyph at that same column (a letter↔letter flip in the shared middle, a letter↔blank flip at the edges as a centered word grows/shrinks). So a scene change is a column-by-column flip cascade, never an instant positional slide — verified: WELCOME → DISCOVER MY CRAFT assembles left to right, each column flipping (D..DI..DISC WELCOME → DISCOWELCOME → ... → DISCOVER MY CRAFT). Removed the anchorWidth machinery (prop, STUDIO_BOARD_ANCHOR_WIDTH, the left-hug centering); toSharedGrid + wrapToGrid now center each text on its own width. The existing front-flip render already produces per-column flips, so centering is the only change needed. Tests: replaced the fixed-column regression test with two that match the new model — `[pickets] every scene title is CENTERED` (proven fail-before: WELCOME leftPad 1 vs rightPad 16 when left-hugged) and `[pickets] a scene change FLIPS column by column` (asserts many distinct intermediate boards = a flip cascade, not a ~2-board instant slide). Made the FREEZES test poll-for-stable to remove a catch-up-loop timing race. Board cluster green; 6 visual baselines unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/components/SplitFlap/index.tsx | 68 ++++----- bytesofpurpose-blog/src/pages/index.tsx | 12 -- bytesofpurpose-blog/test/e2e/homepage.spec.ts | 144 ++++++++++++------ 3 files changed, 123 insertions(+), 101 deletions(-) diff --git a/bytesofpurpose-blog/src/components/SplitFlap/index.tsx b/bytesofpurpose-blog/src/components/SplitFlap/index.tsx index 4ed167b45..59f4c5a56 100644 --- a/bytesofpurpose-blog/src/components/SplitFlap/index.tsx +++ b/bytesofpurpose-blog/src/components/SplitFlap/index.tsx @@ -41,13 +41,6 @@ export interface SplitFlapProps { * any instant only the column the scroll is impacting animates (no whole-board churn or cascade). */ sweepFromText?: string; sweepProgress?: number; - /** ANCHOR WIDTH: center every message as if the longest possible message were this many chars wide, - * so EVERY message occupies the SAME fixed columns regardless of its own length. Without this, each - * title is centered on its own width, so changing between two different-length titles SHIFTS the - * shared letters sideways (they slide into a new centered position) instead of flipping in place. - * Pass the width of the widest message the board will ever show (e.g. the longest scene title) and - * every scene change becomes a pure per-column card-flip with no horizontal slide. */ - anchorWidth?: number; /** DIRECT mode (timer mode only): each cell flips STRAIGHT to the target glyph in a single fold, * instead of rolling through the deck. Off by default (the hero keeps the deck roll). */ direct?: boolean; @@ -149,33 +142,35 @@ function wrapLines(text: string, columns: number): string[] { /** Word-wrap `text` into lines of at most `columns` chars (like a real board), then CENTER each line * within the board and pad so every row is exactly `columns` wide (blank filler tiles fill the rest). - * Returns the grid as equal-length strings. By default each message is centered on its OWN width. Pass - * `anchorWidth` (the widest message the board will ever show) to center every line as if it were that - * wide instead — so a short and a long message START at the SAME left column and shared letters flip in - * place across a message change rather than sliding sideways. */ -function wrapToGrid(text: string, columns: number, anchorWidth?: number): string[] { + * Returns the grid as equal-length strings. Each message is centered on its OWN width, so short titles + * (WELCOME) sit DEAD-CENTER and long ones fill more of the row, with blank flap tiles flanking each + * centered title. When the message changes, the board's fixed-slot cells FLIP to their new glyph (a + * card-flip, never an instant slide) — centering just decides which slot holds which letter. */ +function wrapToGrid(text: string, columns: number): string[] { const lines = wrapLines(text, columns); return lines.map((l) => { - const basis = Math.min(columns, Math.max(l.length, anchorWidth ?? l.length)); - const left = Math.floor((columns - basis) / 2); + const left = Math.floor((columns - l.length) / 2); return padInColumns(l, columns, left); }); } /** SWEEP-mode grids: lay TWO texts on the SAME column grid so a column-wise splice between them is * coherent (the pickets board shows the destination left of the flip front + the from-text to its - * right). Both are single-line and padded to the fixed row count, so column i is the same slot in both. - * The centering basis is `anchorWidth` when given (the widest message across the WHOLE journey), which - * is what makes every crossing agree on one offset — otherwise centering on this pair's own max width - * makes a scene's letters JUMP between adjacent crossings (its offset differs by pair). Returns - * `[toGrid, fromGrid]`. If either text wraps to more than one line (longer than `columns`), falls back - * to per-text `toGrid` centering (multi-line sweep is not used by the hero; scene titles are single-line). */ + * right). Each text is CENTERED on its own width (dead-center on the board) and padded to the fixed row + * count. Returns `[toGrid, fromGrid]`. + * + * Every board COLUMN is a static physical slot. The sweep front (see the caller) walks L→R; each column + * flips between its `fromGrid[i]` char (the current word) and its `grid[i]` char (the upcoming word) at + * that SAME slot. When a word grows/shrinks or a centered word shifts across a crossing, the columns + * whose glyph changes FLIP in place (a Cell flips whenever its `char` changes) — it is never an instant + * positional slide, because nothing MOVES: only each slot's glyph changes, and only by a card-flip. + * Centering (not the earlier shared anchor-width, which left-hugged short titles like WELCOME) keeps + * every settled title dead-center. */ function toSharedGrid( toText: string, fromText: string, columns: number, fixedRows: number | undefined, - anchorWidth?: number, ): [string[], string[]] { const a = toText.toUpperCase(); const b = fromText.toUpperCase(); @@ -185,15 +180,7 @@ function toSharedGrid( const top = Math.floor((fixedRows - g.length) / 2); return [...Array(top).fill(blank), ...g, ...Array(fixedRows - g.length - top).fill(blank)]; }; - // Multi-line (a title wider than the board) has no single shared offset — fall back per-text. - if (a.length > columns || b.length > columns) { - return [pad(wrapToGrid(a, columns, anchorWidth)), pad(wrapToGrid(b, columns, anchorWidth))]; - } - // Center BOTH on the journey-wide anchor width (falls back to this pair's max if no anchor given), so - // column i is the same slot in every crossing and shared letters never slide between crossings. - const basis = Math.min(columns, Math.max(a.length, b.length, anchorWidth ?? 0)); - const left = Math.floor((columns - basis) / 2); - return [pad([padInColumns(a, columns, left)]), pad([padInColumns(b, columns, left)])]; + return [pad(wrapToGrid(a, columns)), pad(wrapToGrid(b, columns))]; } // The flap DECK: the ordered set of glyphs on a physical split-flap drum. A cell rolls THROUGH this @@ -320,14 +307,12 @@ function SplitFlap({ spinning, sweepFromText, sweepProgress, - anchorWidth, direct = false, }: SplitFlapProps): React.JSX.Element { - // Build the centered/padded GRID for a message. `anchorWidth` (when set) centers every message on one - // fixed width so changing messages flips letters in place instead of sliding them to a new center. + // Build the centered/padded GRID for a message (each message dead-centered on its own width). const toGrid = (s: string): string[] => { const upper = s.toUpperCase(); - let g = columns ? wrapToGrid(upper, columns, anchorWidth) : [upper]; + let g = columns ? wrapToGrid(upper, columns) : [upper]; if (columns && fixedRows) { if (g.length > fixedRows) g = g.slice(0, fixedRows); const blank = ' '.repeat(columns); @@ -347,13 +332,14 @@ function SplitFlap({ // instant is the single fold of the cell the front is crossing (Cell flips when its char changes). // Stop mid-crossing → the mixed board FREEZES; scroll back → the letters revert, column by column. if (sweepProgress != null && sweepFromText != null) { - // BOTH texts must sit on the SAME column grid, or the mid-sweep column splice (destination on the - // left of the front, from-text on the right) is INCOHERENT: `toGrid` centers each text on its own - // width, so two titles of different length land in different columns and the splice drops the - // inter-word space ("EXPLORE MY" → "EXPLOREMY") or doubles a boundary letter. `toSharedGrid` - // centers both on the LONGER title's width, so column i means the same slot in both and the swept - // line reads as one continuous title with its spaces intact. - const [grid, fromGrid] = toSharedGrid(text, sweepFromText, columns, fixedRows, anchorWidth); + // Both texts are laid on the board CENTERED (toSharedGrid), each on its own width. Every column is + // a static slot. The front walks L→R across the LIT SPAN (the union of columns where EITHER text + // has a letter): a column the front has PASSED shows its destination glyph (`grid[i]`, which may be + // BLANK at the edges when the destination is the shorter word), a column ahead shows its from glyph + // (`fromGrid[i]`). So as the front crosses each slot, that slot flips from its from-char to its + // to-char — a letter→letter flip in the shared middle, a letter↔blank flip at the edges where the + // words differ in length. Nothing slides; a centered word grows/shrinks by its edge slots flipping. + const [grid, fromGrid] = toSharedGrid(text, sweepFromText, columns, fixedRows); const p = Math.min(1, Math.max(0, sweepProgress)); // Each row's lit span (union across from+to); the front walks these spans row-major. const spans = grid.map((row, r) => { diff --git a/bytesofpurpose-blog/src/pages/index.tsx b/bytesofpurpose-blog/src/pages/index.tsx index 88391980b..88a145600 100644 --- a/bytesofpurpose-blog/src/pages/index.tsx +++ b/bytesofpurpose-blog/src/pages/index.tsx @@ -425,15 +425,6 @@ const STUDIO_INTERVAL_MS = 4500; // time one state (door OR a scene) is shown be // flap tiles flanking the centered title (a real departure board's empty flaps). const STUDIO_BOARD_COLS = 24; const STUDIO_BOARD_ROWS = 3; // 3 rows of bigger letters (not 5 rows of small) -// The WIDEST board message across the whole journey (every scene title + the door's WELCOME). The -// pickets board centers EVERY message on this one width (SplitFlap `anchorWidth`) so each scene sits in -// the SAME columns in every crossing. Without it, each title is centered on its own width, so a scene's -// letters SLIDE sideways when you scroll from one crossing into the next (they re-center to a different -// pair's max width) instead of flipping in place. Computed from the titles so it can never drift. -const STUDIO_BOARD_ANCHOR_WIDTH = Math.max( - 'WELCOME'.length, - ...CHOOSER_CARDS.map((c) => stripEmoji(c.title).length), -); // The door↔scene WHITE FLASH: the centre arch flashes white (a long camera-exposure bloom); at the // flash PEAK the centre swaps door↔scene and the board flips; then the flash recedes. The flash // duration is MATCHED to the board roll (FLASH_SETTLE_MS) so the new scene + the settled board ARRIVE @@ -1271,9 +1262,6 @@ function StudioFacade({ columns={boardCols} rows={STUDIO_BOARD_ROWS} settleMs={FLASH_SETTLE_MS} - // PICKETS: anchor every message to the widest title so scenes sit in fixed columns - // across crossings (no sideways slide on a scene change; letters flip in place). - anchorWidth={picketed ? STUDIO_BOARD_ANCHOR_WIDTH : undefined} /> diff --git a/bytesofpurpose-blog/test/e2e/homepage.spec.ts b/bytesofpurpose-blog/test/e2e/homepage.spec.ts index e6629eca8..815894160 100644 --- a/bytesofpurpose-blog/test/e2e/homepage.spec.ts +++ b/bytesofpurpose-blog/test/e2e/homepage.spec.ts @@ -747,9 +747,9 @@ test.describe('Homepage hero: scroll-driven parallax (variant C)', () => { return (b?.innerText || '').replace(/\s+/g, '').replace(/(.)\1/g, '$1'); }); - // The board's per-column string with LEADING SPACES preserved, plus the column the text starts at. - // (boardCollapsed strips spaces; this one keeps position so we can see a horizontal SHIFT.) - const boardLeftOffset = (page: Page) => + // The board's leading + trailing blank-tile counts for the settled row (so we can check CENTERING: + // a centered title has leftPad ≈ rightPad; a left-hugged one has leftPad ≪ rightPad). + const boardPadding = (page: Page) => page.evaluate(() => { const row = [...document.querySelectorAll('[class*="studioSign"] [class*="row"]')] .map((r) => @@ -758,52 +758,91 @@ test.describe('Homepage hero: scroll-driven parallax (variant C)', () => { .join(''), ) .find((s) => s.trim()); - return row ? row.search(/\S/) : -1; + if (!row) return { left: -1, right: -1 }; + const left = row.search(/\S/); + const right = row.length - 1 - [...row].reverse().findIndex((c) => c !== ' '); + return { left, right: row.length - 1 - right }; }); - test('[pickets] a scene title occupies the SAME columns across crossings (letters flip, never slide)', async ({ + test('[pickets] every scene title is CENTERED on the board (short titles are not left-hugged)', async ({ page, }) => { - // REGRESSION: each scene title was centered on its OWN width, so a title's letters SLID sideways - // when you scrolled from its settled zone into the next crossing (the two crossings re-centered on - // different pair-max widths). The user saw "sentences shifting left" instead of flipping in place. - // Fixed by anchoring every message to one journey-wide width (SplitFlap anchorWidth), so a given - // scene sits in the SAME start column everywhere. Assert that: the SAME title read at two scroll - // positions (its own settled zone, then just past it entering the next crossing) starts at the same - // column. A slide would show two different offsets. + // REGRESSION: an earlier fix anchored every title to one journey-wide LEFT column so shared letters + // would not slide — but that LEFT-HUGGED short titles (WELCOME rendered jammed at the left edge with + // ~16 blank tiles trailing, clearly off-center). The board must keep every title DEAD-CENTER. We + // read the leading vs trailing blank-tile counts of the settled row at the door (WELCOME, short) and + // a scene (a longer title) and assert they are balanced (centered), not lopsided-left. await page.goto(heroUrl('pickets'), { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle'); - // scene 0 (DISCOVER MY CRAFT, 17 chars) settled zone, then the start of the CRAFT→JOURNEY crossing - // (JOURNEY is 19 chars — a DIFFERENT width, the case that used to shift). - await scrubTo(page, (1 + 0.1) / 8); // scene 0 settled + // the door: WELCOME (7 chars) is the SHORTEST message — the worst case for left-hugging. + await scrubTo(page, 0.03); await page.waitForTimeout(500); - const settledText = await boardCollapsed(page); - const settledOffset = await boardLeftOffset(page); - expect(settledText, 'settled on scene 0 title').toBe('DISCOVERMYCRAFT'); + const welcome = await boardCollapsed(page); + const wp = await boardPadding(page); + expect(welcome, 'settled on the door shows WELCOME').toBe('WELCOME'); + expect( + Math.abs(wp.left - wp.right), + `WELCOME must be CENTERED (leftPad ${wp.left} ≈ rightPad ${wp.right}), not jammed to one edge`, + ).toBeLessThanOrEqual(1); - // nudge to the very start of the next crossing, where CRAFT is still the fully-shown FROM text - await scrubTo(page, (1 + 0.75) / 8); + // a scene title (longer) is also centered. + await scrubTo(page, (1 + 0.1) / 8); await page.waitForTimeout(500); - const crossingText = await boardCollapsed(page); - const crossingOffset = await boardLeftOffset(page); - - // if CRAFT is still fully shown here, its start column MUST match the settled one (no slide) - if (crossingText === 'DISCOVERMYCRAFT') { - expect( - crossingOffset, - `CRAFT must start at the SAME column in both places (settled ${settledOffset}, crossing ${crossingOffset}) — a mismatch is the sideways slide`, - ).toBe(settledOffset); - } - // Regardless: every title shares ONE anchor offset, so scene 0's and the next scene's titles both - // start at the same column. Read scene 1's settled title and assert it shares scene 0's offset. - await scrubTo(page, (2 + 0.1) / 8); // scene 1 settled (DISCOVER MY JOURNEY, a different length) - await page.waitForTimeout(500); - const scene1Offset = await boardLeftOffset(page); + const scene0 = await boardCollapsed(page); + const sp = await boardPadding(page); + expect(scene0, 'settled on scene 0 title').toBe('DISCOVERMYCRAFT'); expect( - scene1Offset, - `different-length titles must share the anchor start column (scene0 ${settledOffset}, scene1 ${scene1Offset})`, - ).toBe(settledOffset); + Math.abs(sp.left - sp.right), + `scene 0 title must be CENTERED (leftPad ${sp.left} ≈ rightPad ${sp.right})`, + ).toBeLessThanOrEqual(1); + }); + + test('[pickets] a scene change FLIPS column by column (no instant positional slide)', async ({ page }) => { + // The board's columns are static physical slots: when the content changes (a new/shifted centered + // word), each affected column CARD-FLIPS to its new glyph — it never slides the text sideways in one + // step. We scrub the door→scene0 crossing (WELCOME centered → DISCOVER MY CRAFT centered, the LARGEST + // shift) in fine steps and collect the distinct boards. A flip cascade shows the destination + // ASSEMBLING gradually (many distinct intermediate boards, growing letter by letter); an instant + // slide would jump from WELCOME straight to the full title in ~1 step. + await page.goto(heroUrl('pickets'), { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle'); + + const distinct = await page.evaluate(async () => { + const spacer = document.querySelector('[class*="parallaxSpacer"]') as HTMLElement; + const rect = spacer.getBoundingClientRect(); + const scrollable = rect.height - window.innerHeight; + const top = rect.top + window.scrollY; + const board = () => { + const rows = [...document.querySelectorAll('[class*="studioSign"] [class*="row"]')]; + return ( + rows + .map((row) => + [...row.querySelectorAll('[class*="cell"]')] + .map((c) => c.querySelector('[class*="glyph"]')?.textContent ?? ' ') + .join(''), + ) + .find((s) => s.trim()) || '' + ); + }; + window.scrollTo(0, Math.round(top + 0.02 * scrollable)); + window.dispatchEvent(new Event('scroll')); + await new Promise((r) => setTimeout(r, 500)); + const seen = new Set(); + for (let i = 0; i <= 30; i++) { + const pp = 0.03 + (0.11 * i) / 30; // across the door→scene0 crossing + window.scrollTo(0, Math.round(top + pp * scrollable)); + window.dispatchEvent(new Event('scroll')); + await new Promise((r) => requestAnimationFrame(() => r(null))); + await new Promise((r) => setTimeout(r, 80)); + seen.add(board().replace(/\s+$/g, '')); + } + return seen.size; + }); + + // A column-by-column flip cascade produces MANY distinct intermediate boards as the title assembles. + // An instant slide would produce only ~2-3. Require a healthy cascade. + expect(distinct, `the crossing must flip column by column (saw ${distinct} distinct boards)`).toBeGreaterThanOrEqual(8); }); test('[pickets] the board FREEZES mid-crossing as a stable old/new MIX, and shows the TITLE when settled', async ({ @@ -816,19 +855,28 @@ test.describe('Homepage hero: scroll-driven parallax (variant C)', () => { await page.waitForLoadState('networkidle'); await scrubTo(page, 0.09); // mid door→scene0 crossing - await page.waitForTimeout(600); // let the last front-flip finish - const mid1 = await boardCollapsed(page); - await page.waitForTimeout(600); - const mid2 = await boardCollapsed(page); - expect(mid1, 'the mixed board holds frozen mid-crossing').toBe(mid2); + // The board is scroll-scrubbed via an always-on catch-up loop, so it may still be converging right + // after scrubTo. POLL until it holds STILL (two equal reads a beat apart + nothing flipping) rather + // than assuming a fixed wait is enough — that removes the timing race while proving the FREEZE. + let mid1 = ''; + await expect + .poll( + async () => { + const a = await boardCollapsed(page); + await page.waitForTimeout(250); + const b = await boardCollapsed(page); + const flipping = await page.evaluate( + () => document.querySelectorAll('[class*="studioSign"] [class*="foldDown"]').length, + ); + mid1 = b; + return a === b && flipping === 0; + }, + { timeout: 5000, message: 'the mixed board must hold FROZEN (stable + nothing flipping) mid-crossing' }, + ) + .toBe(true); // the MIX: the destination's prefix has landed, but the full title has not expect(mid1.startsWith('DIS'), `left prefix shows the NEXT title (was "${mid1}")`).toBe(true); expect(mid1, 'mid-crossing is a mix, not the finished title').not.toBe('DISCOVERMYCRAFT'); - // nothing animates at rest: no fold leaf exists anywhere in the board - const flipping = await page.evaluate( - () => document.querySelectorAll('[class*="studioSign"] [class*="foldDown"]').length, - ); - expect(flipping, 'no cell flips while frozen mid-crossing').toBe(0); // Settle past the wave: the full title, held. await scrubTo(page, (1 + 0.25) / 8); From 205121969a864c8743af0990af8d44cabda1714b Mon Sep 17 00:00:00 2001 From: Omar Eid Date: Sun, 12 Jul 2026 07:56:02 -0500 Subject: [PATCH 2/2] docs(hero-skill): capture the board centering model + the two dead ends (per this session's fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update maintain-homepage-hero to reflect the final Vestaboard behavior: - Gotcha 11 rewritten: the "centered AND no sideways slide" model (every column is a static slot that card-flips its glyph; nothing moves horizontally), plus the TWO rejected approaches that must not return — a per-crossing shared width (letters jump between crossings, the "sentences shifting left" bug) and a journey-wide anchorWidth (left-hugs short titles like WELCOME). The answer is plain per-title centering. Names the guarding tests. - Gotcha 24 (the sweep contract) gains an ALIGNMENT trap (e) cross-referencing gotcha 11, and lists the new "FLIPS column by column" guard. SplitFlap lives in the blog's src/components (not @omars-lab/blog-ui), so maintain-homepage-hero is the sole owning skill; modify-blog-ui-component does not apply. Hero-anchors validator still green (53 symbols). Co-Authored-By: Claude Opus 4.8 --- .../skills/maintain-homepage-hero/SKILL.md | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.claude/skills/maintain-homepage-hero/SKILL.md b/.claude/skills/maintain-homepage-hero/SKILL.md index 1e6ccdedc..192892a22 100644 --- a/.claude/skills/maintain-homepage-hero/SKILL.md +++ b/.claude/skills/maintain-homepage-hero/SKILL.md @@ -260,13 +260,25 @@ geometry + the festoon + the board; only the crossing visual + the snap differ ( ONLY while scrolling the parallax (when the facade is on a GPU layer). Fix: `-2px` (an overlap big enough to always cover, regardless of the device grid). Diagnose by live-disabling each candidate (`filter`/overflow/border/margin) one at a time and re-shooting MID-SCROLL at DPR 2 AND 3. -11. **The board TITLES are CENTERED per-message; the board is WIDER than the longest title.** - `wrapToGrid` centers each line on its own width (so short titles like WELCOME sit dead-center), - and `STUDIO_BOARD_COLS` (24 desktop) / `STUDIO_BOARD_COLS_MOBILE` (22) are wider than the longest - title (19) so every title has blank flap tiles flanking it. (An earlier shared-width-stable scheme - kept shared-prefix cells from flipping but left short titles left-hugging; the user chose true - centering, accepting that CRAFT↔JOURNEY re-centers ~19 cells.) Don't re-narrow the board to the - title width or centering loses its margin. +11. **The board TITLES are CENTERED per-message; changes FLIP column by column, never slide.** + `wrapToGrid` centers each line on its OWN width (so short titles like WELCOME sit dead-center), and + `STUDIO_BOARD_COLS` (24 desktop) / `STUDIO_BOARD_COLS_MOBILE` (22) are wider than the longest title + (~22) so every title has blank flap tiles flanking it. The mental model that makes both "centered" + AND "no sideways slide" true at once: **every board column is a STATIC physical slot.** When the + content changes (a new or re-centered word), each affected slot CARD-FLIPS from its old glyph to its + new glyph (a letter↔letter flip in the shared middle, a letter↔blank flip at the edges as a centered + word grows/shrinks). Nothing MOVES horizontally — only each slot's glyph changes, and only by a + flip. So WELCOME→DISCOVER MY CRAFT assembles left-to-right column by column + (`D..DI..DISC WELCOME → DISCOWELCOME → … → DISCOVER MY CRAFT`), it does not slide the text over. + **Two dead ends the user rejected — do NOT reintroduce either:** (a) a per-CROSSING shared width + (`Math.max(from,to)`) made a scene's letters JUMP between adjacent crossings (its center differed by + pair — the "sentences shifting left" bug); (b) a single journey-wide `anchorWidth` (widest title) + stopped the jump but LEFT-HUGGED short titles (WELCOME jammed at column 1 with ~16 trailing blanks). + The answer is plain per-title centering (`wrapToGrid`/`toSharedGrid` both center each text on its own + width) — the existing front-flip render already turns every slot change into a flip, so centering is + all that's needed. Guarded by `[pickets] every scene title is CENTERED` (fail-before: WELCOME leftPad + 1 vs rightPad 16 when left-hugged) + `[pickets] a scene change FLIPS column by column`. Don't + re-narrow the board to the title width (centering loses its margin) and don't add an `anchorWidth`. 12. **`.studioBoardRail` is a MOBILE-ONLY gold railing** in the teal gap below the hanging board / above the door (the side-window railings are hidden on mobile). It's `display:none` by default and shown only in the `≤600px` media query. The dev-only-surfaces e2e doesn't guard it (it's real @@ -404,9 +416,12 @@ geometry + the festoon + the board; only the crossing visual + the snap differ ( (d) BLANK-target cells stay blank in both modes. The board pads short text to a fixed 3-row grid; if the 2 padding rows ever filled, all rows would fill and then COLLAPSE 3-rows→1 in one frame (a jarring jump). The sweep's lit span never touches them, and spinning-mode padding cells never churn. + (e) ALIGNMENT: `toSharedGrid` centers BOTH texts each on its OWN width (per-title centering) — see + gotcha 11 for the full centered-AND-no-slide model + the two dead ends (per-pair shared width, and a + journey-wide `anchorWidth`) that must not come back. Guarded by `[pickets] the board stays a SINGLE row through a crossing`; the sweep contract itself is guarded by `[pickets] the board FREEZES mid-crossing as a stable old/new MIX` + `[pickets] the board - letters land column by column WITH the scroll`. + letters land column by column WITH the scroll` + `[pickets] a scene change FLIPS column by column`. 25. **FIREFOX-ONLY: split-flap letters "glitch downward" mid-crossing — fix it on the FOLD LEAVES, NOT every glyph (that lags scroll).** During a flap, each leaf clips a full-height `.glyph` and rotates