diff --git a/src/App.css b/src/App.css index 99f06328..b9b949d5 100644 --- a/src/App.css +++ b/src/App.css @@ -5443,17 +5443,63 @@ body.has-player .chat-fab { 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; +/* The lane and its gutter belong to the feed, not to any one block: the + composer cuts the mosaic into several grids and they all have to line up + on the same edges. */ +.journal-feed { margin: 0 auto; padding: 1.25rem; + max-width: 1180px; + width: 100%; + box-sizing: border-box; +} + +.journal-grid { + list-style: none; + margin: 0; + padding: 0; display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 9rem; gap: 0.75rem; - max-width: 1180px; - width: 100%; - box-sizing: border-box; +} + +/* ── Sub-block breaks ────────────────────────────────────────────── + Three custom properties are the whole contract a theme writes against: + the air around the break, the rule through it, and a glyph centred on + that rule. `none` on the last two is the real absence of each — no + border, and no pseudo-element at all — so a theme that wants no + separation zeroes the space and drops the rule, and the break collapses + to nothing rather than to an empty band. Themes wanting something a rule + can't be (a gradient, a short centred pill) set height and background + here directly; the properties are the convenient path, not the only one. */ +.journal-break { + --journal-break-space: 1.6rem; + --journal-break-rule: 1px solid var(--color-border); + --journal-break-ornament: none; + + position: relative; + height: 0; + margin: var(--journal-break-space) 0; + border: none; + border-top: var(--journal-break-rule); + overflow: visible; +} + +/* Page-coloured, so the glyph opens a gap in the rule exactly as wide as + itself instead of sitting on top of it. */ +.journal-break::before { + content: var(--journal-break-ornament); + position: absolute; + top: 50%; + left: 50%; + translate: -50% -50%; + padding: 0 0.8em; + background: var(--color-bg); + color: var(--color-text-muted); + font-family: var(--font-display, inherit); + font-size: var(--text-sm); + line-height: 1; } /* ── Footprints ── */ @@ -5745,10 +5791,13 @@ body.has-player .chat-fab { } @media (max-width: 460px) { + .journal-feed { + padding: 0.85rem 1rem; + } + .journal-grid { grid-template-columns: 1fr; grid-auto-rows: auto; - padding: 0.85rem 1rem; } /* Collapse the mosaic into a single readable column. */ diff --git a/src/pages/index/JournalFeed.tsx b/src/pages/index/JournalFeed.tsx index db648af5..8633669a 100644 --- a/src/pages/index/JournalFeed.tsx +++ b/src/pages/index/JournalFeed.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { Fragment, useMemo } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ErrorCard } from "../../components/ErrorCard.tsx"; @@ -21,7 +21,7 @@ export function JournalFeed( removeVote, }: MainFeedProps, ) { - const entries = useMemo(() => composeJournal(dumps), [dumps]); + const blocks = useMemo(() => composeJournal(dumps), [dumps]); if (loading) { return ( @@ -31,7 +31,7 @@ export function JournalFeed( ); } if (error) return ; - if (entries.length === 0) { + if (blocks.length === 0) { return (

No dumps yet. Be the first! @@ -41,28 +41,39 @@ export function JournalFeed( return ( <> -

+
{loadingMore && (

Loading more…

)} - {!hasMore && entries.length > 0 && ( + {!hasMore && blocks.length > 0 && (

You've reached the end.

diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index 53650f4b..59067524 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -179,6 +179,17 @@ transform: none; } +/* ── Journal sub-block breaks ────────────────────────────────────── */ +/* The theme already has one divider and uses it everywhere — the app header, + the modal header, a section heading all end on 2px of --color-border. So + the journal break is that same edge, not a new mark invented for it. What + separates a break from any other rule here is the air around it, which is + the one thing a heading can't spend. */ +[data-style="brutalist"] .journal-break { + --journal-break-space: 2.1rem; + --journal-break-rule: 2px solid var(--color-border); +} + /* ── Dump post detail header ─────────────────────────────────────── */ [data-style="brutalist"] .dump-post-header { border: 2px solid var(--color-border); diff --git a/src/themes/geocities.css b/src/themes/geocities.css index 6cc411bc..9184487a 100644 --- a/src/themes/geocities.css +++ b/src/themes/geocities.css @@ -268,6 +268,20 @@ box-shadow: 0 0 16px color-mix(in srgb, var(--color-accent) 75%, transparent); } +/* The divider GIF, minus the GIF: the rainbow the header ends on, run again + between sub-blocks with a sparkle sitting in the middle of it. */ +[data-style="geocities"] .journal-break { + --journal-break-space: 1.5rem; + --journal-break-rule: 4px solid; + --journal-break-ornament: "✦ ✦ ✦"; + border-image: var(--geo-rainbow) 1; +} + +[data-style="geocities"] .journal-break::before { + color: var(--color-accent); + letter-spacing: 0.15em; +} + /* ── App header — rainbow divider ────────────────────────────────── */ [data-style="geocities"] .app-header, [data-style="geocities"] .header-search-below { diff --git a/src/themes/kanagawa.css b/src/themes/kanagawa.css index 7bd92d10..d0ff614e 100644 --- a/src/themes/kanagawa.css +++ b/src/themes/kanagawa.css @@ -170,6 +170,14 @@ color: var(--kngw-string); } +/* Sub-blocks part on a comment divider — the dashed rule and the ellipsis a + source file uses to mark a section, set in the mono face the theme reads in. */ +[data-style="kanagawa"] .journal-break { + --journal-break-space: 1.9rem; + --journal-break-rule: 1px dashed var(--color-border); + --journal-break-ornament: "· · ·"; +} + /* Vote tallies read as number literals */ [data-style="kanagawa"] .vote-btn:not(.vote-btn--active) { color: var(--kngw-number); diff --git a/src/themes/melange.css b/src/themes/melange.css index d8683926..3438df02 100644 --- a/src/themes/melange.css +++ b/src/themes/melange.css @@ -331,6 +331,19 @@ font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 70, "WONK" 0; } +/* The mélange runs along the header's bottom edge; between sub-blocks it + returns as a thinner, softer band of the same blend — the page divided by + its own palette rather than by a line. A rule can't carry a gradient, so + this one is a filled bar and the base break's border stands down. */ +[data-style="melange"] .journal-break { + --journal-break-space: 2rem; + --journal-break-rule: none; + height: 3px; + border-radius: 999px; + background: var(--mel-spice); + opacity: 0.55; +} + /* Vote tallies wear the dusty rose of the palette's far end */ [data-style="melange"] .vote-btn:not(.vote-btn--active) { color: var(--mel-rose); diff --git a/src/themes/nyt.css b/src/themes/nyt.css index 3eed7166..926703c4 100644 --- a/src/themes/nyt.css +++ b/src/themes/nyt.css @@ -256,11 +256,16 @@ the rules read as continuous row + column delimiters (à la nytimes.com). 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. */ + +/* How far a photo holds off the rules. It sits on the feed rather than on the + grid because the break between two grids is cut to the same measure, and a + property declared on the grid wouldn't reach it. */ +[data-style="nyt"] .journal-feed { + --journal-art-inset: 0.55rem; +} [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; @@ -277,22 +282,35 @@ — 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. */ + One page-coloured frame, laid over the grid's own 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. One frame per grid + means one per sub-block, so every block reads frameless, not just the feed. */ [data-style="nyt"] .journal-grid::before { content: ""; position: absolute; - inset: 1.25rem; + inset: 0; 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 { - inset: 0.85rem 1rem; - } + +/* A section front changes section on a rule, not an ornament — and here on a + double one, the paired hairline a paper sets above a new section. Both + lines are the grid's own rule colour and weight, so the break reads as the + same pen that divides the cells, used twice. It needs less air than the + base break asks for: each block already ends and begins on the + page-coloured frame, which is holding its own rules back by the art inset. */ +[data-style="nyt"] .journal-break { + --journal-break-space: 1rem; + --journal-break-rule: 1px solid var(--color-border); + height: 3px; + border-bottom: 1px solid var(--color-border); + /* Stop where the art stops. The frame above holds the grid's own rules + back by the inset, so a full-width break would overshoot the pictures + it divides by exactly that much on each side. */ + margin: var(--journal-break-space) var(--journal-art-inset); } [data-style="nyt"] .journal-card:hover { border-color: var(--color-border); diff --git a/src/utils/journalLayout.ts b/src/utils/journalLayout.ts index 57c7954b..40e043c2 100644 --- a/src/utils/journalLayout.ts +++ b/src/utils/journalLayout.ts @@ -15,9 +15,19 @@ export interface JournalEntry { shape: JournalShape; } +/** A run of entries rendered as one grid, separated from the next by a break. */ +export interface JournalBlock { + /** Stable list key — the id of the dump the block opens on. */ + key: string; + entries: JournalEntry[]; +} + /** Columns the mosaic is composed against — the grid's widest breakpoint. */ const COLUMNS = 4; +/** Cards a sub-block gathers before a break comes due. */ +const BLOCK_CARDS = 12; + const FOOTPRINT: Record = { feature: { cols: 2, rows: 2 }, wide: { cols: 2, rows: 1 }, @@ -133,22 +143,67 @@ function leadWithArt(sorted: Dump[]): Dump[] { /** * 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, composed so the four columns close on every row. + * pull-quotes, composed so the four columns close on every row — and cut into + * sub-blocks so the feed breathes instead of running on as one slab. + * + * A break can only fall where a row both starts clean and isn't reached into + * from above, which the exact tiling makes cheap to test: every row closes + * flush, so the only boundaries ruled out are the ones a portrait or a hero + * straddles. Past that, two things set the rhythm. + * + * It comes due on a count of *cards*, not of rows. A block is a set of + * stories, and a hero is one story however many cells it takes, so a stretch + * thick with big footprints reaches the count over more rows than a stretch + * of squares does. Block heights vary with the size mix rather than beating + * out a fixed measure — and because the mix is itself content-driven (only + * dumps with art may claim the big footprints), so is the rhythm. + * + * And the break *makes* its own section front rather than waiting for one: + * the card that opens a new block is promoted to a hero. Waiting was the + * mistake — a banner almost never lands at the start of a row on its own, so + * a rule preferring one would almost never fire. Promotion always fits: the + * break only falls on a row with all four cells free. + * + * The whole thing stays a pure function of the ranking, so no break shuffles + * under the reader when infinite scroll appends the next page. */ -export function composeJournal(dumps: Dump[]): JournalEntry[] { +export function composeJournal(dumps: Dump[]): JournalBlock[] { const ordered = leadWithArt( [...dumps].sort((a, b) => hotScore(b) - hotScore(a)), ); const mosaic = createMosaic(COLUMNS); + const straddled = new Set(); + const blocks: JournalBlock[] = []; + let entries: JournalEntry[] = []; - return ordered.map((dump, index) => { + ordered.forEach((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 }; + const remaining = ordered.length - index - 1; + // The tail guard: cutting within a few cards of the end leaves a rule + // with a fragment under it, which reads as a mistake rather than as a + // division. Only the bottom of the feed can be affected, and that's the + // part infinite scroll is about to extend anyway. + const breaking = + entries.length >= BLOCK_CARDS && + remaining >= BLOCK_CARDS / 2 && + slot.c === 0 && + !straddled.has(slot.r); + + const shape = breaking + ? fit(hasThumbnail(dump) ? "feature" : "wide", slot.free, remaining) + : fit(desiredShape(dump, index), slot.free, remaining); + + const span = FOOTPRINT[shape]; + mosaic.occupy(slot, span); + if (span.rows > 1) straddled.add(slot.r + 1); + + if (breaking) { + blocks.push({ key: entries[0].dump.id, entries }); + entries = []; + } + entries.push({ dump, shape }); }); + + if (entries.length > 0) blocks.push({ key: entries[0].dump.id, entries }); + return blocks; }