diff --git a/index.html b/index.html
index 4d94d156..ef1496bb 100644
--- a/index.html
+++ b/index.html
@@ -10,7 +10,7 @@
-
+
diff --git a/src/contexts/ThemeContext.ts b/src/contexts/ThemeContext.ts
index 1c073e0b..924b0c86 100644
--- a/src/contexts/ThemeContext.ts
+++ b/src/contexts/ThemeContext.ts
@@ -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 {
diff --git a/src/main.tsx b/src/main.tsx
index a8925178..fb8404e5 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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();
diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx
index b7f3f5b3..d0dceb6c 100644
--- a/src/pages/UserPublicProfile.tsx
+++ b/src/pages/UserPublicProfile.tsx
@@ -1238,6 +1238,13 @@ export function UserPublicProfile() {
>
Kanagawa
+
diff --git a/src/themes/melange.css b/src/themes/melange.css
new file mode 100644
index 00000000..d8683926
--- /dev/null
+++ b/src/themes/melange.css
@@ -0,0 +1,337 @@
+/* ══════════════════════════════════════════════════════════════════════
+ 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;
+}
+
+/* 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);
+}