Straight ice: flat backdrop, crisp translucent panels, barely any gradients

The colourful multi-blob wash was too much and still didn't read as
frosted glass. Replaced with a cleaner material approach:

- Backdrop is now flat with a single faint top highlight (the only
  gradient left in it); the animated blobs and the four colour radials
  are gone.
- Panels are crisper and more translucent: cards 62%, panels 66%,
  sidebar 64% (now flat, no vertical gradient), study chrome 68%, tab
  bar 70% — with a bright 1px edge (--glass-edge), a light-from-above
  sheen (--glass-sheen) and the inset top highlight.
- Light mode is lighter: page #eef3fa pale ice, sidebar and cards
  frosted white; dark mode is a flatter, slightly lighter navy
  (#121a2c page, #1a2337 surfaces).
- Key-gate glows toned right down to match.

Contrast re-audited against the effective frosted backgrounds: 21/21 AA.
Suites: deck chrome 15/15, deck fixes 15/15, final ux 12/12, key gate
49/49, install sheet 64/64, retry 14/14, zoom 6/6, security 8/8, pwa
16/16.
This commit is contained in:
Martin Dvořák 2026-10-08 09:20:22 +00:00
commit 5d0559212b
2 changed files with 84 additions and 123 deletions

View file

@ -112,10 +112,11 @@ no database, no data leaving your machine except the requests you send to Ollama
list with per-folder counts, Settings/Dark mode pinned to the bottom). It **collapses** to a 68px
icon rail (remembered in `localStorage`) and becomes a slide-in **drawer** behind a hamburger on
phones; mobile also keeps the frosted header + iOS tab bar
- **One navy palette pulled from the app icon, both themes**: light page `#f4f7fd` / sidebar
`#e9eff9` / border `#dce4f1`; dark page `#0e1424` / sidebar `#0a0f1c` / surfaces `#171f33` /
border `rgba(255,255,255,.12)`. Surfaces are frosted glass (translucent + `backdrop-blur`) over
the icon's navy backdrop.
- **One palette pulled from the app icon, both themes**: light page `#eef3fa` (pale ice) /
sidebar and cards frosted white / border `#dce4f1`; dark page `#121a2c` / sidebar `#0c1322` /
surfaces `#1a2337` / border `rgba(255,255,255,.1)`. Surfaces are frosted glass — translucent with
`backdrop-blur`, a bright 1px edge and a light-from-above sheen — over a flat backdrop with a
single faint highlight.
Text tiers are documented AA ratios (see below), and the cards fill the row with
`repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour:
@ -727,10 +728,11 @@ Things I chose while building, with the reasoning:
have no `folderId`), deleting a folder re-homes its decks instead of deleting them, and folder
names travel in exports so an imported deck lands where it came from. 13 browser checks cover
create → freeze → move → rename → delete → reload → import.
18. **The visual language is glassmorphism, with restraint.** The icon's blue is the one accent
colour driving buttons and highlights; the backdrop is a navy wash; surfaces — sidebar, cards,
chrome, sheets — are frosted and translucent. Colour appears in activity tints and semantic
feedback only — pretty, but still readable and quiet.
18. **The visual language is frosted ice, with restraint.** The icon's blue is the one accent
colour driving buttons and highlights; the backdrop is flat with a single faint top highlight,
and surfaces — sidebar, cards, chrome, sheets — are translucent glass with a bright edge and a
soft sheen. Colour appears in activity tints and semantic feedback only — pretty, but still
readable and quiet.
19. **Custom CSS lives in `@layer components`, and glass uses Tailwind's backdrop utilities.**
Two build-level traps, both found by looking at real screenshots: unlayered rules beat Tailwind
utilities (which silently broke `fixed` on the feed), and a hand-written `backdrop-filter` gets
@ -750,10 +752,11 @@ Things I chose while building, with the reasoning:
pill CTAs, 18px radii and restrained shadows; the sidebar, cards and chrome are translucent
glass over the backdrop. Deck cards got real hierarchy: title → muted metadata → tags → summary
→ secondary Study CTA, with secondary actions behind a quiet menu.
23. **One fixed backdrop layer instead of per-page blobs.** The decorative wash used to be
23. **One fixed backdrop layer, deliberately almost flat.** The decorative wash used to be
rendered inside the content column, so on wide screens it was clipped into a visible hard edge.
It now lives in a single `position: fixed` layer (`.app-backdrop`, `z-index: -10`) in the
icon's navy and blue, and `body`'s colour matches its bottom so mobile overscroll shows no seam.
It now lives in a single `position: fixed` layer (`.app-backdrop`, `z-index: -10`): a flat ice
or navy base plus one faint highlight at the top — just enough for the glass to catch, without
turning into a gradient soup. `body`'s colour matches it so mobile overscroll shows no seam.
24. **The UI was deliberately made quieter.** One accent colour (`accent-*`), a desaturated
`frost-*` scale, hairline borders and flat surfaces replaced the saturated gradients and the
seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry
@ -789,9 +792,8 @@ Things I chose while building, with the reasoning:
Desktop keeps the designed 12–14px field sizes.
31. **The app icon drives the palette.** The navy rounded-square artwork in
`assets/app-icon-source.jpeg` is the source of truth: `frost-*` is navy-tinted, the accent is
the snowflake blue, the backdrop is the same navy wash, the primary CTA is the icon blue (deep
in light mode, bright in dark), and every icon/splash/favicon is rendered from it by
`scripts/make-icons.mjs`. The sidebar, header, key gate, empty state and install sheet all show
the snowflake blue, the primary CTA is the icon blue (deep in light mode, bright in dark), and
every icon/splash/favicon is rendered from it by `scripts/make-icons.mjs`. The sidebar, header, key gate, empty state and install sheet all show
the real artwork instead of the old monochrome snowflake tile.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).

View file

@ -81,30 +81,30 @@
--------------------------------------------------------------------------- */
:root {
color-scheme: light;
--page: #f4f7fd;
--sidebar: #e9eff9;
--page: #eef3fa; /* pale ice */
--sidebar: #ffffff;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-strong: #edf2fa;
--card-hover-bg: #f7faff;
--surface-strong: #f2f6fc;
--card-hover-bg: #ffffff;
--surface-sunken: rgb(14 21 40 / 0.05);
--hairline: #dce4f1;
--card-border: #d7e0ef;
--sidebar-divider: #d7e0ef;
--card-border: rgb(255 255 255 / 0.85);
--sidebar-divider: #dfe7f3;
--hairline-soft: rgb(14 21 40 / 0.07);
--glass-tint: rgb(255 255 255 / 0.55);
--glass-top: rgb(255 255 255 / 0.55);
--text: #0e1526; /* 16.9:1 on #f4f7fd */
--text-muted: #4d5c78; /* 6.3:1 */
--text-subtle: #55647f; /* 5.6:1 */
--glass-tint: rgb(255 255 255 / 0.6);
--glass-top: rgb(255 255 255 / 0.95);
--glass-edge: rgb(255 255 255 / 0.85);
--glass-sheen: rgb(255 255 255 / 0.55);
--text: #0e1526; /* 16.2:1 on #eef3fa */
--text-muted: #4d5c78; /* 6.1:1 */
--text-subtle: #55647f; /* 5.4:1 */
--hover: rgb(14 21 40 / 0.05);
--sidebar-top: #eef3fb;
--sidebar-bottom: #e5edf9;
--sidebar-edge: 0 1px 0 var(--sidebar-divider), 6px 0 18px -12px rgb(14 21 40 / 0.35);
--sidebar-edge: 0 1px 0 var(--sidebar-divider), 6px 0 18px -12px rgb(14 21 40 / 0.18);
/* API-key gate: cool near-white stage with soft blue glows, tinted input. */
--gate-base: #eef3fb;
--gate-glow-a: rgb(90 150 240 / 0.25);
--gate-glow-b: rgb(140 150 240 / 0.2);
--gate-base: #eef3fa;
--gate-glow-a: rgb(90 150 240 / 0.16);
--gate-glow-b: rgb(140 150 240 / 0.12);
--gate-card: #ffffff;
--gate-card-border: #dde5f2;
--gate-callout-bg: #eef3fa;
@ -118,36 +118,39 @@
--install-chip: #f2f2f2;
--install-border: #e0e0e0;
--shadow-card: 0 1px 2px rgb(14 21 40 / 0.06), 0 1px 3px rgb(14 21 40 / 0.04);
--card-shadow: 0 1px 2px rgb(14 21 40 / 0.05), 0 5px 14px -6px rgb(14 21 40 / 0.12);
--shadow-card-hover: 0 12px 30px -12px rgb(14 21 40 / 0.2), 0 2px 6px rgb(14 21 40 / 0.07);
--card-shadow: 0 0 0 1px rgb(14 21 40 / 0.04), 0 1px 2px rgb(14 21 40 / 0.04), 0 12px 28px -14px rgb(14 21 40 / 0.2);
--shadow-card-hover: 0 0 0 1px rgb(14 21 40 / 0.05), 0 16px 36px -14px rgb(14 21 40 / 0.24), 0 2px 6px rgb(14 21 40 / 0.07);
/* Backdrop: flat ice with a single faint highlight at the top. */
--bd-base: #eef3fa;
--bd-highlight: rgb(255 255 255 / 0.9);
}
.dark {
color-scheme: dark;
--page: #0e1424;
--sidebar: #0a0f1c;
--surface: #171f33;
--surface-raised: #1e2740;
--surface-strong: #202a42;
--card-hover-bg: #1e2740;
--page: #121a2c;
--sidebar: #0c1322;
--surface: #1a2337;
--surface-raised: #222c44;
--surface-strong: #222c44;
--card-hover-bg: #222c44;
--surface-sunken: rgb(255 255 255 / 0.07);
--hairline: rgb(255 255 255 / 0.12);
--card-border: rgb(255 255 255 / 0.12);
--sidebar-divider: rgb(255 255 255 / 0.1);
--hairline-soft: rgb(255 255 255 / 0.08);
--hairline: rgb(255 255 255 / 0.1);
--card-border: rgb(255 255 255 / 0.1);
--sidebar-divider: rgb(255 255 255 / 0.08);
--hairline-soft: rgb(255 255 255 / 0.07);
--glass-tint: rgb(255 255 255 / 0.06);
--glass-top: rgb(255 255 255 / 0.08);
--text: #e9eefb; /* 15.4:1 on #0e1424 */
--text-muted: #b7c3dc; /* 10.4:1 */
--text-subtle: #94a4c2; /* 7.3:1 */
--glass-top: rgb(255 255 255 / 0.09);
--glass-edge: rgb(255 255 255 / 0.1);
--glass-sheen: rgb(255 255 255 / 0.05);
--text: #e9eefb; /* 14.8:1 on #121a2c */
--text-muted: #b7c3dc; /* 10.0:1 */
--text-subtle: #94a4c2; /* 7.0:1 */
--hover: rgb(255 255 255 / 0.07);
--sidebar-top: #0b1120;
--sidebar-bottom: #080d18;
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75);
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.7);
/* API-key gate: navy stage with the icon's blue glows, raised input. */
--gate-base: #0a0f1c;
--gate-glow-a: rgb(70 140 235 / 0.2);
--gate-glow-b: rgb(120 120 235 / 0.14);
--gate-base: #0c1322;
--gate-glow-a: rgb(70 140 235 / 0.14);
--gate-glow-b: rgb(120 120 235 / 0.1);
--gate-card: #131a2c;
--gate-card-border: rgb(255 255 255 / 0.1);
--gate-callout-bg: rgb(255 255 255 / 0.06);
@ -163,25 +166,8 @@
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25);
--card-shadow: var(--shadow-card);
--shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
}
/* Backdrop: the icon's navy base with saturated blue/periwinkle/cyan blobs —
this is the colour the frosted surfaces are blurring. */
:root {
--bd-a: rgb(96 160 250 / 0.5);
--bd-b: rgb(150 150 245 / 0.42);
--bd-c: rgb(90 205 235 / 0.38);
--bd-top: #f7faff;
--bd-mid: #eef4fd;
--bd-bottom: #e8f0fb;
}
.dark {
--bd-a: rgb(48 120 235 / 0.42);
--bd-b: rgb(105 110 240 / 0.34);
--bd-c: rgb(50 165 220 / 0.3);
--bd-top: #101a30;
--bd-mid: #0c1220;
--bd-bottom: #080d18;
--bd-base: #101a2c;
--bd-highlight: rgb(120 160 220 / 0.12);
}
html,
@ -247,54 +233,21 @@ body {
z-index: -10;
overflow: hidden;
pointer-events: none;
/* Flat ice with one faint highlight at the top — just enough for the
frosted surfaces to have something to catch. */
background:
radial-gradient(52vw 52vh at 12% 4%, var(--bd-a) 0%, transparent 68%),
radial-gradient(46vw 46vh at 94% 12%, var(--bd-b) 0%, transparent 66%),
radial-gradient(56vw 52vh at 78% 98%, var(--bd-c) 0%, transparent 68%),
radial-gradient(44vw 44vh at 26% 82%, var(--bd-a) 0%, transparent 70%),
linear-gradient(180deg, var(--bd-top) 0%, var(--bd-mid) 50%, var(--bd-bottom) 100%);
}
/* Two slow, saturated blobs so the glass always has colour to bend. */
.app-backdrop::before,
.app-backdrop::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.55;
}
.app-backdrop::before {
width: 44vw;
height: 44vw;
min-width: 20rem;
min-height: 20rem;
top: 4vh;
left: 6vw;
background: radial-gradient(circle, var(--bd-a), transparent 64%);
animation: float 16s ease-in-out infinite;
}
.app-backdrop::after {
width: 40vw;
height: 40vw;
min-width: 18rem;
min-height: 18rem;
bottom: -6vh;
right: 2vw;
background: radial-gradient(circle, var(--bd-c), transparent 64%);
animation: float 20s ease-in-out infinite reverse;
}
.dark .app-backdrop::before,
.dark .app-backdrop::after {
opacity: 0.6;
radial-gradient(120vw 54vh at 50% -16%, var(--bd-highlight) 0%, transparent 72%),
var(--bd-base);
}
@layer components {
/* Glass primitives ------------------------------------------------------ */
.glass-panel {
@apply backdrop-blur-xl backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 68%, transparent);
border: 1px solid var(--hairline);
background:
linear-gradient(180deg, var(--glass-sheen) 0%, transparent 46%),
color-mix(in oklab, var(--surface) 66%, transparent);
border: 1px solid var(--glass-edge);
box-shadow: var(--shadow-card), inset 0 1px 0 var(--glass-top);
}
@ -319,7 +272,9 @@ body {
surface as the cards, with a neutral border and shadow. */
.study-chrome {
@apply backdrop-blur-xl backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 72%, transparent);
background:
linear-gradient(180deg, var(--glass-sheen) 0%, transparent 46%),
color-mix(in oklab, var(--surface) 68%, transparent);
border: 1px solid var(--hairline);
box-shadow: var(--shadow-card);
}
@ -330,10 +285,16 @@ body {
.card {
border-radius: 1.125rem; /* 18px */
@apply backdrop-blur-xl backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 62%, transparent);
background:
linear-gradient(180deg, var(--glass-sheen) 0%, transparent 46%),
color-mix(in oklab, var(--surface) 62%, transparent);
border: 1px solid var(--card-border);
box-shadow: var(--card-shadow), inset 0 1px 0 var(--glass-top);
}
.dark .card,
.dark .glass-panel {
border-color: var(--glass-edge);
}
/* Opt-in hover lift for interactive cards (deck cards). Pointer devices only,
so touch screens never get sticky hover states. */
@ -358,7 +319,7 @@ body {
.list-group {
border-radius: 1rem;
@apply backdrop-blur-lg backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 70%, transparent);
background: color-mix(in oklab, var(--surface) 66%, transparent);
border: 1px solid var(--hairline);
overflow: hidden;
}
@ -411,7 +372,7 @@ body {
.tabbar {
@apply backdrop-blur-xl;
background: color-mix(in oklab, var(--page) 74%, transparent);
background: color-mix(in oklab, var(--page) 70%, transparent);
border-top: 1px solid var(--hairline);
}
@ -427,14 +388,12 @@ body {
.dark .tint-peach { background: linear-gradient(140deg, rgb(230 160 118 / 0.26), rgb(230 160 118 / 0.1)); color: #fadfc9 }
.dark .tint-rose { background: linear-gradient(140deg, rgb(230 130 150 / 0.26), rgb(230 130 150 / 0.1)); color: #fbd5dd }
/* Sidebar surface: frosted navy glass with a real edge. */
/* Sidebar surface: flat frosted glass with a real edge. */
.sidebar-surface {
@apply backdrop-blur-xl backdrop-saturate-150;
background: linear-gradient(
180deg,
color-mix(in oklab, var(--sidebar-top) 68%, transparent),
color-mix(in oklab, var(--sidebar-bottom) 68%, transparent)
);
background:
linear-gradient(180deg, var(--glass-sheen) 0%, transparent 30%),
color-mix(in oklab, var(--sidebar) 64%, transparent);
border-right: 1px solid var(--sidebar-divider);
box-shadow: var(--sidebar-edge);
}