v3: the journal mosaic closes its rows — dense auto-flow was papering over a tiler that didn't tile: a 2-column banner meeting a 1-column gap wrapped, left a hole, and dense filled it by pulling a later card up out of rank order. So the composer now walks its own occupancy grid in reading order and clamps each footprint to the run of cells actually free to the right of the cursor (a banner that meets one cell becomes a square rather than wrapping), plus drops the second row of a portrait within a row of the end, where nothing is left to fill the cells beside it. Four columns close on every row, no gap to backfill, and the order read is the order ranked — dense stays only below the full width, where four-column tiling no longer describes the grid and a hole is the worse alternative. The page also leads with art now: when the hottest dump is a bare note, the first picture within three places of it takes the front instead, since the top few sit within noise of each other on score and which one opens a section front is an editorial call. nyt's frameless block follows: rules overshoot their cell so neighbours meet and read continuous, which leaves ticks around the outside of the grid, and one page-coloured frame as thick as the art inset cuts them all back at once — replacing the two strips that only masked the first column and first row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-20 07:46:24 +00:00
parent 27c55ea96f
commit a0738f5fc5
3 changed files with 121 additions and 34 deletions

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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<JournalShape, { cols: number; rows: number }> = {
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 };
});
}