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 }; + }); }