From a0738f5fc537879a1857475acc85ce9a7be6cbc8 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 20 Sep 2026 07:46:24 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20journal=20mosaic=20closes=20its=20r?= =?UTF-8?q?ows=20=E2=80=94=20dense=20auto-flow=20was=20papering=20over=20a?= =?UTF-8?q?=20tiler=20that=20didn't=20tile:=20a=202-column=20banner=20meet?= =?UTF-8?q?ing=20a=201-column=20gap=20wrapped,=20left=20a=20hole,=20and=20?= =?UTF-8?q?`dense`=20filled=20it=20by=20pulling=20a=20later=20card=20up=20?= =?UTF-8?q?out=20of=20rank=20order.=20So=20the=20composer=20now=20walks=20?= =?UTF-8?q?its=20own=20occupancy=20grid=20in=20reading=20order=20and=20cla?= =?UTF-8?q?mps=20each=20footprint=20to=20the=20run=20of=20cells=20actually?= =?UTF-8?q?=20free=20to=20the=20right=20of=20the=20cursor=20(a=20banner=20?= =?UTF-8?q?that=20meets=20one=20cell=20becomes=20a=20square=20rather=20tha?= =?UTF-8?q?n=20wrapping),=20plus=20drops=20the=20second=20row=20of=20a=20p?= =?UTF-8?q?ortrait=20within=20a=20row=20of=20the=20end,=20where=20nothing?= =?UTF-8?q?=20is=20left=20to=20fill=20the=20cells=20beside=20it.=20Four=20?= =?UTF-8?q?columns=20close=20on=20every=20row,=20no=20gap=20to=20backfill,?= =?UTF-8?q?=20and=20the=20order=20read=20is=20the=20order=20ranked=20?= =?UTF-8?q?=E2=80=94=20`dense`=20stays=20only=20below=20the=20full=20width?= =?UTF-8?q?,=20where=20four-column=20tiling=20no=20longer=20describes=20th?= =?UTF-8?q?e=20grid=20and=20a=20hole=20is=20the=20worse=20alternative.=20T?= =?UTF-8?q?he=20page=20also=20leads=20with=20art=20now:=20when=20the=20hot?= =?UTF-8?q?test=20dump=20is=20a=20bare=20note,=20the=20first=20picture=20w?= =?UTF-8?q?ithin=20three=20places=20of=20it=20takes=20the=20front=20instea?= =?UTF-8?q?d,=20since=20the=20top=20few=20sit=20within=20noise=20of=20each?= =?UTF-8?q?=20other=20on=20score=20and=20which=20one=20opens=20a=20section?= =?UTF-8?q?=20front=20is=20an=20editorial=20call.=20nyt's=20frameless=20bl?= =?UTF-8?q?ock=20follows:=20rules=20overshoot=20their=20cell=20so=20neighb?= =?UTF-8?q?ours=20meet=20and=20read=20continuous,=20which=20leaves=20ticks?= =?UTF-8?q?=20around=20the=20outside=20of=20the=20grid,=20and=20one=20page?= =?UTF-8?q?-coloured=20frame=20as=20thick=20as=20the=20art=20inset=20cuts?= =?UTF-8?q?=20them=20all=20back=20at=20once=20=E2=80=94=20replacing=20the?= =?UTF-8?q?=20two=20strips=20that=20only=20masked=20the=20first=20column?= =?UTF-8?q?=20and=20first=20row.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- src/App.css | 11 +++- src/themes/nyt.css | 43 +++++++--------- src/utils/journalLayout.ts | 101 ++++++++++++++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 34 deletions(-) diff --git a/src/App.css b/src/App.css index c91808a7..b1dfbcb0 100644 --- a/src/App.css +++ b/src/App.css @@ -5439,7 +5439,10 @@ body.has-player .chat-fab { A composed scrapbook: one hero feature plus an asymmetric mix of banners, portraits, squares and pull-quotes. Footprints are set by the layout composer; how a card paints (image / quote / text) follows its - content. Dense auto-flow backfills the gaps the varied spans leave. */ + content. The composer clamps every footprint to the cells left in its row, + so the four columns close on every row with no gap to backfill — and + without `dense` moving a later card up into one, the order cards are read + in stays the order they were ranked in. */ .journal-grid { list-style: none; margin: 0 auto; @@ -5447,7 +5450,6 @@ body.has-player .chat-fab { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 9rem; - grid-auto-flow: dense; gap: 0.75rem; max-width: 1180px; width: 100%; @@ -5715,9 +5717,14 @@ body.has-player .chat-fab { opacity: 0.55; } +/* Below the full width the composer's four-column tiling no longer describes + the grid, so a banner can meet a single free cell and wrap. Dense auto-flow + comes back to backfill what that leaves — worth the reordering here, where + the alternative is a hole. */ @media (max-width: 980px) { .journal-grid { grid-template-columns: repeat(3, 1fr); + grid-auto-flow: dense; } } diff --git a/src/themes/nyt.css b/src/themes/nyt.css index 819fab07..7a3c5810 100644 --- a/src/themes/nyt.css +++ b/src/themes/nyt.css @@ -254,11 +254,13 @@ /* Journal view: a section-front grid of stories divided by hairline rules, not boxed cards. Close the gaps and rule each cell on its top & left edge so the rules read as continuous row + column delimiters (à la nytimes.com). - The left-most column's rule is masked off (below) so there's no outer frame - on the left — CSS grid can't target "first column", hence the mask. */ + The outermost rules are masked off (below) so there's no frame around the + block — CSS grid can't target a first column or a last row, hence the mask. */ [data-style="nyt"] .journal-grid { gap: 0; position: relative; + /* How far a photo holds off the rules; the mask is cut to match. */ + --journal-art-inset: 0.55rem; } [data-style="nyt"] .journal-card { background: transparent; @@ -268,37 +270,28 @@ box-shadow: none; } -/* Page-coloured strips over the grid's content-left and content-top edges, - hiding the first column's left rule and the first row's top rule (offsets - track the grid's padding-left / padding-top). */ +/* Rules run to the edges of their cell, but a photo holds off them by + --journal-art-inset. Between two cells the overshoot meets the neighbour's + and the pair reads as one continuous rule; around the outside of the block + there is no neighbour, so each one ends as a tick sticking out past the art + — above and below it at the top and bottom of the grid, past its sides at + the left and right. + + One page-coloured frame, laid over the grid's content edge and as thick as + that inset, cuts all of them back to the art at once. It takes the outer + rules (the first row's top, the first column's left) with it, which is the + same frameless block the two strips it replaces were after. */ [data-style="nyt"] .journal-grid::before { content: ""; position: absolute; - top: 0; - bottom: 0; - left: 1.25rem; - width: 1px; - background: var(--color-bg); - z-index: 1; - pointer-events: none; -} -[data-style="nyt"] .journal-grid::after { - content: ""; - position: absolute; - top: 1.25rem; - left: 0; - right: 0; - height: 1px; - background: var(--color-bg); + inset: 1.25rem; + border: var(--journal-art-inset) solid var(--color-bg); z-index: 1; pointer-events: none; } @media (max-width: 460px) { [data-style="nyt"] .journal-grid::before { - left: 1rem; - } - [data-style="nyt"] .journal-grid::after { - top: 0.85rem; + inset: 0.85rem 1rem; } } [data-style="nyt"] .journal-card:hover { diff --git a/src/utils/journalLayout.ts b/src/utils/journalLayout.ts index 05a6914e..57c7954b 100644 --- a/src/utils/journalLayout.ts +++ b/src/utils/journalLayout.ts @@ -15,6 +15,16 @@ export interface JournalEntry { shape: JournalShape; } +/** Columns the mosaic is composed against — the grid's widest breakpoint. */ +const COLUMNS = 4; + +const FOOTPRINT: Record = { + feature: { cols: 2, rows: 2 }, + wide: { cols: 2, rows: 1 }, + tall: { cols: 1, rows: 2 }, + square: { cols: 1, rows: 1 }, +}; + /** A dump that can carry a real preview image (file image or rich thumbnail). */ export function hasThumbnail(dump: Dump): boolean { if (dump.kind === "file") { @@ -33,15 +43,46 @@ export function hasQuote(dump: Dump): boolean { } /** - * Pick a grid footprint for a single entry. + * Occupancy of the mosaic, walked in reading order. + * + * `next()` returns the first cell no card has claimed yet, along with the run + * of free cells to its right — the width the next card is allowed to take. + * Because every card is clamped to that run (see `fit`), the cursor never has + * to skip a cell it can't fill, so the tiling is exact: no gaps, and the order + * cards are emitted in is the order they're read in. + */ +function createMosaic(columns: number) { + const rows: boolean[][] = []; + const row = (r: number) => (rows[r] ??= new Array(columns).fill(false)); + let cursor = 0; + + return { + next() { + while (row(Math.floor(cursor / columns))[cursor % columns]) cursor++; + const r = Math.floor(cursor / columns); + const c = cursor % columns; + let free = 0; + while (c + free < columns && !row(r)[c + free]) free++; + return { r, c, free }; + }, + occupy(at: { r: number; c: number }, span: { cols: number; rows: number }) { + for (let dr = 0; dr < span.rows; dr++) { + for (let dc = 0; dc < span.cols; dc++) row(at.r + dr)[at.c + dc] = true; + } + }, + }; +} + +/** + * The footprint an entry would *like*, before it's clamped to the grid. * * Variety is composed, not tiered: a repeating 11-step rhythm spreads tall and * wide accents through the feed instead of bucketing every card by score. The - * rhythm is content-aware — only dumps with a thumbnail are allowed to claim the - * image-forward `tall`/`wide`/`feature` footprints; everything else stays a + * rhythm is content-aware — only dumps with a thumbnail are allowed to claim + * the image-forward `tall`/`wide`/`feature` footprints; everything else stays a * compact `square` so quote and text cards never stretch awkwardly. */ -function pickShape(dump: Dump, index: number): JournalShape { +function desiredShape(dump: Dump, index: number): JournalShape { const thumb = hasThumbnail(dump); if (index === 0) return thumb ? "feature" : "wide"; @@ -56,12 +97,58 @@ function pickShape(dump: Dump, index: number): JournalShape { return "square"; } +/** + * Shrink a footprint until it fits the cells actually on offer. + * + * Two cuts. Horizontally, a 2-column card that meets a 1-column gap gives up a + * column rather than wrapping — wrapping is what leaves a hole behind. And a + * card within a row of the end drops its second row: nothing is left to fill + * the cells beside a portrait that hangs off the bottom of the feed. + */ +function fit( + shape: JournalShape, + free: number, + remaining: number, +): JournalShape { + let { cols, rows } = FOOTPRINT[shape]; + if (cols > free) cols = 1; + if (rows > 1 && remaining < COLUMNS) rows = 1; + if (cols === 2) return rows === 2 ? "feature" : "wide"; + return rows === 2 ? "tall" : "square"; +} + +/** + * A section front leads with a picture. When the hottest dump is a bare note, + * promote the first piece of art from just behind it rather than opening the + * page on an empty banner — the top few are within noise of each other on + * score anyway, and which one leads is an editorial call, not a ranking one. + */ +function leadWithArt(sorted: Dump[]): Dump[] { + if (sorted.length < 2 || hasThumbnail(sorted[0])) return sorted; + const lead = sorted.findIndex(hasThumbnail); + if (lead < 1 || lead > 3) return sorted; + return [sorted[lead], ...sorted.filter((_, i) => i !== lead)]; +} + /** * Sort dumps by hotness, then lay them out as an editorial mosaic: one hero * feature followed by an asymmetric mix of banners, portraits, squares and - * pull-quotes. Dense grid auto-flow backfills the gaps these spans create. + * pull-quotes, composed so the four columns close on every row. */ export function composeJournal(dumps: Dump[]): JournalEntry[] { - const sorted = [...dumps].sort((a, b) => hotScore(b) - hotScore(a)); - return sorted.map((dump, index) => ({ dump, shape: pickShape(dump, index) })); + const ordered = leadWithArt( + [...dumps].sort((a, b) => hotScore(b) - hotScore(a)), + ); + const mosaic = createMosaic(COLUMNS); + + return ordered.map((dump, index) => { + const slot = mosaic.next(); + const shape = fit( + desiredShape(dump, index), + slot.free, + ordered.length - index - 1, + ); + mosaic.occupy(slot, FOOTPRINT[shape]); + return { dump, shape }; + }); }