Compare commits

...

4 Commits

Author SHA1 Message Date
khannurien
e4d50f047c 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
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>
2026-09-20 08:01:42 +00:00
khannurien
6449569b34 v3: the quote card's opening mark lands on the text — two things had it reading as a smudge in the corner rather than as the note's own quotation mark. The card clips its overflow, so hanging the glyph off the top edge never bled it past the corner; it just cut away the half carrying the ink. And a fixed right offset put what survived outside the text column, floating against the card's edge instead of lining up with the words it belongs to. So the whole glyph stays inside the card and is set against the very padding the note is set against — one custom property now, since nyt pads these cards more than the base theme does — and the top offset takes back the leading above the ink so the mark starts on the note's first line rather than below it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 07:46:30 +00:00
khannurien
a0738f5fc5 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>
2026-09-20 07:46:24 +00:00
khannurien
27c55ea96f v3: melange joins the styles — savq/melange-nvim's palette taken verbatim (the a.* neutrals, b.yellow as the accent since it's the scheme's Function colour, the dusty c.* row as furniture), but built as a warm 70s print instead of a second editor skin: one cut of Fraunces with opsz pinned low, so a headline and the caption under it stay the same face; pillowy borderless cards lifted on warm sunlight shadow rather than held inside a line; the mélange itself — every desert hue blended once — laid along the bottom edge of the header; and grain over the whole page, which has to differ per scheme because soft-light grey noise is invisible on a pale ground: fine film grain on the coffee dark, a coarser multiply tooth on the plaster light, both switched through custom properties since light-dark() only carries colours. Light keeps the warmth in the page and the bleach in the cards — a.float underneath, tints above — since melange's own light bg reads grey next to its floats; the header takes a lighter shade of its own so it sits over the page as a board.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:32:26 +00:00
12 changed files with 696 additions and 69 deletions

View File

@@ -10,7 +10,7 @@
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png?v=__ICON_VERSION__" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Saira:ital,wght@0,100..900;1,100..900&family=Space+Grotesk:wght@400;700;900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..900,0..100,0..1;1,9..144,300..900,0..100,0..1&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Saira:ital,wght@0,100..900;1,100..900&family=Space+Grotesk:wght@400;700;900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#01152a" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf5e9" />

View File

@@ -5439,21 +5439,69 @@ 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. */
.journal-grid {
list-style: none;
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. */
/* 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;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 9rem;
grid-auto-flow: dense;
gap: 0.75rem;
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;
}
/* ── 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 ── */
.journal-card--feature {
grid-column: span 2;
@@ -5630,15 +5678,25 @@ body.has-player .chat-fab {
/* ── Quote cards — the note is the subject, on an accent wash ── */
.journal-card--quote {
padding: 0.9rem;
--journal-quote-pad: 0.9rem;
padding: var(--journal-quote-pad);
gap: 0.4rem;
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
}
/* Two things had this mark reading as a smudge in the corner rather than as
the note's own opening quote. The card clips its overflow, so hanging the
glyph off the top edge didn't bleed it past the corner — it cut away the
half that carries the ink. And a fixed offset put what was left outside the
text column, floating against the card's edge instead of lining up with the
words it belongs to. So: keep the whole glyph inside the card, and set it
against the same padding the note is set against. The leading above the ink
is what the top offset takes back, so the mark starts on the note's first
line rather than below it. */
.journal-card-quotemark {
position: absolute;
top: -0.5rem;
right: 0.4rem;
top: calc(var(--journal-quote-pad) - 0.75rem);
right: var(--journal-quote-pad);
font-family: var(--font-display, Georgia, "Times New Roman", serif);
font-size: 4.5rem;
line-height: 1;
@@ -5715,9 +5773,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;
}
}
@@ -5728,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. */

View File

@@ -1,7 +1,12 @@
import { createContext } from "react";
export type StyleName =
"smooth" | "brutalist" | "geocities" | "nyt" | "kanagawa";
| "smooth"
| "brutalist"
| "geocities"
| "nyt"
| "kanagawa"
| "melange";
export type ColorScheme = "auto" | "light" | "dark";
export interface ThemeContextValue {

View File

@@ -12,6 +12,7 @@ import "./themes/brutalist.css";
import "./themes/geocities.css";
import "./themes/nyt.css";
import "./themes/kanagawa.css";
import "./themes/melange.css";
await loadCatalog();

View File

@@ -1238,6 +1238,13 @@ export function UserPublicProfile() {
>
Kanagawa
</button>
<button
type="button"
className={style === "melange" ? "active" : ""}
onClick={() => setStyle("melange")}
>
Melange
</button>
</div>
</div>
<div className="profile-appearance-row">

View File

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

View File

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

View File

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

View File

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

350
src/themes/melange.css Normal file
View File

@@ -0,0 +1,350 @@
/* ══════════════════════════════════════════════════════════════════════
Melange theme — after savq/melange-nvim, whose palette is pure 1970s
desert: toasted amber, terracotta, olive, dusty rose, laid on roasted
coffee (dark) or warm plaster (light).
So this is not an editor skin — it's a warm print. The scheme's own
colours are kept exactly; what's built on them is analog: a soft
Windsor-ish serif, film grain over the whole page, pillowy borderless
cards that sit in warm shadow rather than inside a line, and a spice
gradient — the mélange itself, every hue blended — along the header.
Dark scheme → melange dark (#292522 grounds, cream ink).
Light scheme → melange light (plaster, and every ink is a brown).
══════════════════════════════════════════════════════════════════════ */
/* ── Palette (light-dark: see tokens.css) ─────────────────────────── */
:root[data-style="melange"] {
/* One family, soft-serif: Fraunces sits at the Souvenir/Cooper end of
the 70s, which is exactly where this palette lives.
Its optical-size axis is the catch — left on `auto`, a headline is
cut as a high-contrast Didone while the caption under it is a sturdy
slab, and one family starts reading as three. So opsz is pinned low
and everywhere: sturdy, low-contrast, warm at every size. Any rule
that touches SOFT or WONK repeats opsz, since font-variation-settings
replaces the whole list rather than merging into it. */
--font-body: "Fraunces", Georgia, serif;
--font-display: "Fraunces", Georgia, serif;
--mel-opsz: 14;
/* Nothing here has a sharp corner. */
--radius-xs: 5px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 22px;
/* Text — a.fg / a.com / a.ui */
--color-text: light-dark(#54433a, #ece1d7);
--color-text-secondary: light-dark(#7d6658, #c1a78e);
--color-text-muted: light-dark(#a98a78, #867462);
--color-on-accent: light-dark(#f7f4f1, #292522);
/* Surfaces — sunken < bg < surface < raised.
Dark: below a.bg · a.bg · a.float · a.sel.
Light: a.sel is the well, a.float is the *page* — melange's light
side keeps its warmth in the floats, so on a page made of
cards the plaster belongs underneath and the cards are the
bleached tints above it. Reversing those two tiers is what
keeps the light scheme from reading as grey. */
--color-bg: light-dark(#e9e1db, #292522);
--color-surface: light-dark(#f6f2ef, #34302c);
--color-surface-raised: light-dark(#fdfbf9, #403a36);
--color-surface-sunken: light-dark(#d9d3ce, #201d1b);
/* Borders — a.ui, but mostly unused: this theme separates by warmth
and shadow, not by line. */
--color-border: light-dark(rgb(169 138 120 / 0.38), rgb(134 116 98 / 0.45));
--color-border-subtle: light-dark(
rgb(169 138 120 / 0.18),
rgb(134 116 98 / 0.22)
);
/* Accent — b.yellow, the spice. Hover walks it toward c.yellow's burn. */
--color-accent: light-dark(#a06d00, #ebc06d);
--color-accent-hover: light-dark(#bc5c00, #e49b5d);
--color-accent-soft: light-dark(
rgb(160 109 0 / 0.13),
rgb(235 192 109 / 0.15)
);
--color-focus-ring: light-dark(
rgb(160 109 0 / 0.35),
rgb(235 192 109 / 0.4)
);
/* Links */
--color-link: light-dark(#a06d00, #ebc06d);
--color-link-hover: light-dark(#bc5c00, #e49b5d);
/* Danger — b.red on d.red */
--color-danger: light-dark(#bf0021, #d47766);
--color-danger-bg: light-dark(#f1dedf, #7d2a2f);
--color-danger-hover: light-dark(#c77b8b, #bd8183);
/* Success — b.green on d.green */
--color-success: light-dark(#3a684a, #85b695);
--color-success-bg: light-dark(#d0e9d1, #233524);
--color-success-hover: light-dark(#6e9b72, #78997a);
/* Warning — c.yellow */
--color-warning: light-dark(#bc5c00, #e49b5d);
/* Overlays */
--color-overlay: light-dark(rgb(84 67 58 / 0.3), rgb(32 29 27 / 0.72));
--color-header-user-bg: transparent;
--color-header-user-bg-hover: light-dark(
rgb(84 67 58 / 0.07),
rgb(236 225 215 / 0.07)
);
/* Misc */
--color-option-bg: light-dark(#e9e1db, #34302c);
--color-option-border: light-dark(
rgb(169 138 120 / 0.18),
rgb(134 116 98 / 0.22)
);
/* The rest of the desert — melange's c.* row, dusty rather than bright.
These are the theme's own furniture colours. */
--mel-terracotta: light-dark(#bc5c00, #e49b5d);
--mel-olive: light-dark(#6e9b72, #78997a);
--mel-rose: light-dark(#be79bb, #b380b0);
--mel-clay: light-dark(#c77b8b, #bd8183);
--mel-dust: light-dark(#a98a78, #867462);
/* The mélange: every hue of the desert, blended once, left to right. */
--mel-spice: linear-gradient(
90deg,
var(--color-accent),
var(--mel-terracotta) 32%,
var(--mel-clay) 58%,
var(--mel-rose) 78%,
var(--mel-olive)
);
/* Shadows are warm and wide — sunlight, not a drop shadow. Light needs
more of them than dark: on plaster, shadow is the only thing holding
a card off the page. */
--shadow-color: light-dark(rgb(122 84 46 / 0.26), rgb(12 9 8 / 0.5));
--shadow-color-soft: light-dark(rgb(122 84 46 / 0.16), rgb(12 9 8 / 0.32));
--shadow-color-strong: light-dark(rgb(122 84 46 / 0.36), rgb(12 9 8 / 0.7));
--shadow-1: 0 2px 10px -2px var(--shadow-color-soft);
--shadow-2: 0 14px 34px -10px var(--shadow-color),
0 2px 6px var(--shadow-color-soft);
--shadow-3: 0 32px 70px -16px var(--shadow-color-strong),
0 3px 10px var(--shadow-color-soft);
/* Grain — one tile, two treatments (see body::after). Defaults are the
dark ones; the light scheme overrides them by selector. */
--mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)' opacity='0.22'/%3E%3C/svg%3E");
--mel-grain-blend: soft-light;
--mel-grain-opacity: 0.55;
/* Glow: the spice catches light where it's interactive. */
--mel-glow: 0 3px 16px -4px
light-dark(rgb(160 109 0 / 0.45), rgb(235 192 109 / 0.35));
/* Component skins — no lines anywhere; warmth and shadow do the work. */
--border-width: 1px;
/* The header is a board laid over the page, lighter than it in both
schemes: a.float on coffee, the bleached tint on plaster. */
--header-bg: light-dark(#fdfbf9, #34302c);
--input-focus-bg: var(--color-surface-raised);
--overlay-filter: blur(6px);
--modal-border: transparent;
--card-border: transparent;
--card-border-width: 1px;
--card-shadow: var(--shadow-1);
--card-hover-border: transparent;
--panel-ring: var(--shadow-1);
--tab-border: transparent;
--btn-primary-shadow: var(--mel-glow);
--btn-primary-shadow-hover: 0 5px 22px -4px
light-dark(rgb(160 109 0 / 0.55), rgb(235 192 109 / 0.45));
}
/* ══════════════════════════════════════════════════════════════════════
Structural overrides
══════════════════════════════════════════════════════════════════════ */
/* ── One cut of Fraunces, at one optical size, for every size of text ── */
:root[data-style="melange"],
[data-style="melange"] body {
font-optical-sizing: none;
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 40, "WONK" 0;
}
/* ── The page is sunlit: warm light pools at the top, the ground below
is flat desert. ───────────────────────────────────────────────── */
[data-style="melange"] body {
background-image: radial-gradient(
120% 55% at 50% -12%,
var(--color-accent-soft),
transparent 70%
);
background-repeat: no-repeat;
background-attachment: fixed;
}
/* ── Film grain over everything, so no surface reads as flat pixels.
Sits above the page, below modals and toasts.
The two schemes need opposite grain. On coffee, `soft-light` grits
the surface without lifting the blacks. On plaster that same blend
is invisible — pale grey noise on a pale ground has nothing to
work against — so light multiplies instead, which is what paper
tooth actually is: tiny shadows. Neither blend nor opacity takes
light-dark(), so the two parameters are custom properties and the
scheme is picked by selector below. ───────────────────────────── */
[data-style="melange"] body::after {
content: "";
position: fixed;
inset: 0;
z-index: calc(var(--z-modal) - 1);
pointer-events: none;
opacity: var(--mel-grain-opacity);
mix-blend-mode: var(--mel-grain-blend);
background-image: var(--mel-grain);
}
/* Auto (no forced scheme) follows the OS; the attribute forms win over
it because they come later and are more specific. */
@media (prefers-color-scheme: light) {
:root[data-style="melange"] {
--mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23g)' opacity='0.42'/%3E%3C/svg%3E");
--mel-grain-blend: multiply;
--mel-grain-opacity: 0.85;
}
}
:root[data-style="melange"][data-color-scheme="light"] {
--mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23g)' opacity='0.42'/%3E%3C/svg%3E");
--mel-grain-blend: multiply;
--mel-grain-opacity: 0.85;
}
/* Repeats the fine tile too: forcing dark under a light OS would
otherwise inherit the coarse one from the media query above. */
:root[data-style="melange"][data-color-scheme="dark"] {
--mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)' opacity='0.22'/%3E%3C/svg%3E");
--mel-grain-blend: soft-light;
--mel-grain-opacity: 0.55;
}
@media (prefers-reduced-transparency: reduce) {
[data-style="melange"] body::after {
display: none;
}
}
/* ── Header — a board over the page: no rule under it, just its own
shade, a warm shadow, and the spice laid along its bottom edge as
a single blended stripe. ──────────────────────────────────────── */
[data-style="melange"] .app-header {
border-bottom: none;
box-shadow: var(--shadow-1);
z-index: var(--z-raised);
}
[data-style="melange"] .app-header::after {
content: "";
position: absolute;
inset: auto 0 0 0;
height: 2px;
background: var(--mel-spice);
opacity: 0.7;
}
/* Brand — soft and a little wonky, the way 70s display type was cut. */
[data-style="melange"] .app-header-brand {
color: var(--mel-terracotta);
font-weight: 600;
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 100, "WONK" 1;
letter-spacing: -0.01em;
}
[data-style="melange"] .form-page-title,
[data-style="melange"] .profile-subpage-title,
[data-style="melange"] .profile-section-title,
[data-style="melange"] .comment-section-title,
[data-style="melange"] .related-section-title {
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 80, "WONK" 1;
}
/* Tabs keep the shared uppercase cut, but the active one glows. */
[data-style="melange"] .feed-sort-btn.active {
box-shadow: var(--mel-glow);
}
[data-style="melange"] .feed-sort-btn:hover:not(.active) {
background: var(--color-accent-soft);
}
/* ── Cards sit in the light rather than inside a line; they rise a
little when pointed at. ───────────────────────────────────────── */
[data-style="melange"] .dump-card,
[data-style="melange"] .journal-card,
[data-style="melange"] .playlist-card {
transition:
box-shadow var(--dur-slow) var(--ease-out),
transform var(--dur-slow) var(--ease-out);
}
[data-style="melange"] .dump-card:hover,
[data-style="melange"] .journal-card:hover,
[data-style="melange"] .playlist-card:hover {
box-shadow: var(--shadow-2);
transform: translateY(-2px);
}
@media (prefers-reduced-motion: reduce) {
[data-style="melange"] .dump-card:hover,
[data-style="melange"] .journal-card:hover,
[data-style="melange"] .playlist-card:hover {
transform: none;
}
}
/* ── Metadata is dust: quiet, warm, never black ──────────────────── */
[data-style="melange"] .journal-card:not(.journal-card--image) .journal-card-meta,
[data-style="melange"] .dump-card-date,
[data-style="melange"] .comment-time,
[data-style="melange"] .notification-time {
color: var(--mel-dust);
}
/* ── A quote is a printed pull-quote: big soft mark, terracotta ──── */
[data-style="melange"] .journal-card--quote {
background: var(--color-surface);
}
[data-style="melange"] .journal-card-quotemark {
color: var(--mel-terracotta);
opacity: 0.28;
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 100, "WONK" 1;
}
[data-style="melange"] .journal-card-quote-body {
font-size: var(--text-lg);
line-height: 1.35;
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);
}

View File

@@ -254,8 +254,15 @@
/* 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. */
/* 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;
@@ -268,38 +275,42 @@
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 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;
top: 0;
bottom: 0;
left: 1.25rem;
width: 1px;
background: var(--color-bg);
inset: 0;
border: var(--journal-art-inset) solid 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);
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;
}
/* 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);
@@ -317,7 +328,8 @@
}
[data-style="nyt"] .journal-card--quote,
[data-style="nyt"] .journal-card--text {
padding: 1.05rem;
--journal-quote-pad: 1.05rem;
padding: var(--journal-quote-pad);
}
/* ── App header — crisp masthead rule ────────────────────────────── */

View File

@@ -15,6 +15,26 @@ 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 },
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 +53,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 +107,103 @@ 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 — 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[] {
const sorted = [...dumps].sort((a, b) => hotScore(b) - hotScore(a));
return sorted.map((dump, index) => ({ dump, shape: pickShape(dump, index) }));
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[] = [];
ordered.forEach((dump, index) => {
const slot = mosaic.next();
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;
}