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:
11
src/App.css
11
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user