Compare commits
4 Commits
d70761ecae
...
e4d50f047c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4d50f047c | ||
|
|
6449569b34 | ||
|
|
a0738f5fc5 | ||
|
|
27c55ea96f |
@@ -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" />
|
||||
|
||||
90
src/App.css
90
src/App.css
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
350
src/themes/melange.css
Normal file
350
src/themes/melange.css
Normal 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);
|
||||
}
|
||||
@@ -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 ────────────────────────────── */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user