v3: the journal feed breathes — one mosaic running unbroken down the page reads as a slab, so the composer now cuts it into sub-blocks, each its own grid with a themable rule between. A break can only fall where a row 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.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Two things set the rhythm, and neither is a row counter — that beats like a metronome. It comes due on a count of *cards*: 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, and block heights end up tracking the size mix (measured: four to five rows through square-heavy runs, six to seven where art clusters). And the break *makes* its own section front rather than waiting for one, promoting the card that opens a new block to a hero. Waiting was the first attempt and it failed on the numbers — a banner lands at the start of a row on its own two to five times in thirty-six, so the preference almost never fired and a max-rows cap did all the work. Promotion always fits, since a break only falls on a row with all four cells free. A tail guard keeps the last cut from leaving a rule with a fragment under it. The break sits between two grids rather than inside one: a grid item would be stretched to the 9rem row track, and out here its height is its own, down to nothing. Three custom properties are the whole contract a theme writes against — the air around it, the rule through it, an optional glyph centred on that rule — with `none` on the last two meaning the real absence of each, so a theme can remove the separation entirely rather than leave an empty band. Themes wanting something a rule can't be reach past the properties to plain CSS: nyt pairs hairlines in the grid's own pen and clips them to the art inset (which moves up to the feed, since a property on the grid can't reach a sibling); brutalist spends air around the 2px border it already divides everything else with; kanagawa takes a comment divider, geocities the rainbow its header ends on, melange a thinner band of the mélange. The page gutter moves to a feed wrapper so every block lines up on the same edges, and nyt's masking frame now runs once per block, which is what makes each of them read frameless and not just the feed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
61
src/App.css
61
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. */
|
||||
|
||||
@@ -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 <ErrorCard title={t`Failed to load`} message={error} />;
|
||||
if (entries.length === 0) {
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<p className="index-status">
|
||||
<Trans>No dumps yet. Be the first!</Trans>
|
||||
@@ -41,28 +41,39 @@ export function JournalFeed(
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="journal-grid">
|
||||
{entries.map(({ dump, shape }) => (
|
||||
<JournalCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
shape={shape}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
{/* Each sub-block is its own grid, with a themable <hr> between them.
|
||||
A break sits between two grids rather than inside one because a grid
|
||||
item would be stretched to the 9rem row track — out here its height
|
||||
is its own, down to nothing for a theme that wants no separation. */}
|
||||
<div className="journal-feed">
|
||||
{blocks.map((block, index) => (
|
||||
<Fragment key={block.key}>
|
||||
{index > 0 && <hr className="journal-break" />}
|
||||
<ul className="journal-grid">
|
||||
{block.entries.map(({ dump, shape }) => (
|
||||
<JournalCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
shape={shape}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && (
|
||||
<p className="feed-loading-more">
|
||||
<Trans>Loading more…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{!hasMore && entries.length > 0 && (
|
||||
{!hasMore && blocks.length > 0 && (
|
||||
<p className="feed-end">
|
||||
<Trans>You've reached the end.</Trans>
|
||||
</p>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<JournalShape, { cols: number; rows: number }> = {
|
||||
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<number>();
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user