Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions .claude/skills/maintain-homepage-hero/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
68 changes: 27 additions & 41 deletions bytesofpurpose-blog/src/components/SplitFlap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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) => {
Expand Down
12 changes: 0 additions & 12 deletions bytesofpurpose-blog/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
/>
</div>
</div>
Expand Down
Loading
Loading