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); +}