Navy theme from the app icon, frosted glass, and the new mark in-app

The icon's artwork is now the source of truth for the palette:

- frost-* is a navy-tinted neutral scale and accent-* is the icon's
  snowflake blue; light page #f4f7fd / sidebar #e9eff9 / border
  #dce4f1, dark page #0e1424 / sidebar #0a0f1c / surfaces #171f33.
- Surfaces are frosted glass: .card, .glass-panel, .list-group and
  .sidebar-surface are translucent with backdrop-blur/saturate, and the
  page wrapper no longer paints an opaque --page, so the fixed navy/blue
  backdrop reads through the sidebar, cards, chrome and sheets.
- The primary CTA is the icon's blue (#1d61ab in light, #59a8f7 in dark
  with ink text).
- The gate stage, fields and glows are navy/blue; viewport theme-color
  and the manifest background/theme colour are #0e1424.
- The old monochrome snowflake tile is gone from the brand: sidebar,
  header, key gate and empty state now show the real app icon (the
  install sheet already did). Decorative snowflakes stay.
- --surface-strong is finally defined (it was referenced but never
  declared, so those inputs had no fill).
- Icon URLs carry ?v=2 so browser favicon caches refetch.

Updated the contrast audit (21 pairs) and the deck-chrome token
assertions to the new theme. Verified: contrast 21/21, deck chrome
15/15, key gate 49/49, install sheet 64/64, final ux 12/12, retry
14/14, zoom 6/6, security 8/8, pwa 16/16, deck fixes 15/15.
This commit is contained in:
Martin Dvořák 2026-10-08 06:15:51 +00:00
commit 979f3ddc39
10 changed files with 181 additions and 158 deletions

View file

@ -112,8 +112,10 @@ 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 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 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 phones; mobile also keeps the frosted header + iOS tab bar
- **One neutral palette, both themes**: light page `#ffffff` / sidebar `#f9f9f9` / border `#e5e5e5`; - **One navy palette pulled from the app icon, both themes**: light page `#f4f7fd` / sidebar
dark page `#212121` / sidebar `#171717` / surfaces `#2f2f2f` / border `rgba(255,255,255,.1)`. `#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.
Text tiers are documented AA ratios (see below), and the cards fill the row with 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 `repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour: - Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour:
@ -725,9 +727,10 @@ Things I chose while building, with the reasoning:
have no `folderId`), deleting a folder re-homes its decks instead of deleting them, and folder 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 names travel in exports so an imported deck lands where it came from. 13 browser checks cover
create → freeze → move → rename → delete → reload → import. create → freeze → move → rename → delete → reload → import.
18. **The visual language is glassmorphism, with restraint.** One accent colour drives the buttons 18. **The visual language is glassmorphism, with restraint.** The icon's blue is the one accent
and highlights; the backdrop is a soft aurora wash; surfaces are frosted and translucent. Colour colour driving buttons and highlights; the backdrop is a navy wash; surfaces — sidebar, cards,
appears in activity tints and semantic feedback only — pretty, but still readable and quiet. chrome, sheets — are frosted and translucent. 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.** 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 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 utilities (which silently broke `fixed` on the feed), and a hand-written `backdrop-filter` gets
@ -738,19 +741,19 @@ Things I chose while building, with the reasoning:
a fixture deck into `localStorage` rather than relying on app code. a fixture deck into `localStorage` rather than relying on app code.
21. **Dark mode is token-driven, and contrast is audited.** Text colours come from 21. **Dark mode is token-driven, and contrast is audited.** Text colours come from
`--text` / `--text-muted` / `--text-subtle` (never hardcoded), and a `contrast.mjs` audit checks `--text` / `--text-muted` / `--text-subtle` (never hardcoded), and a `contrast.mjs` audit checks
every pairing at WCAG AA — currently **20/20 pairs pass** (e.g. dark secondary `#b4b4b4` on every pairing at WCAG AA — currently **21/21 pairs pass** (e.g. dark secondary `#b7c3dc` on
`#212121` = 7.8:1). Difficulty colours were darkened until they cleared 4.5:1 on their pills. `#0e1424` = 10.4:1, white on the light blue CTA `#1d61ab` = 6.3:1). Difficulty colours were
darkened until they cleared 4.5:1 on their pills.
This was a real bug: an earlier edit silently failed, so `.dark` never overrode `--text`, leaving This was a real bug: an earlier edit silently failed, so `.dark` never overrode `--text`, leaving
near-black text on a dark page. near-black text on a dark page.
22. **The dashboard follows a neutral, ChatGPT-style system.** Near-white/near-black surfaces, 22. **The dashboard follows the icon's navy system.** Frosted navy/white surfaces, hairline borders,
hairline borders, pill CTAs, 18px radii and restrained shadows; the frost survives in the chrome pill CTAs, 18px radii and restrained shadows; the sidebar, cards and chrome are translucent
(header, tab bar, sheets) rather than in every card, which keeps text crisp and the layout glass over the backdrop. Deck cards got real hierarchy: title → muted metadata → tags → summary
premium. Deck cards got real hierarchy: title → muted metadata → tags → summary → prominent → secondary Study CTA, with secondary actions behind a quiet menu.
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 instead of per-page blobs.** The decorative aurora used to be
rendered inside the content column, so on wide screens it was clipped into a visible hard edge. 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`) with very low It now lives in a single `position: fixed` layer (`.app-backdrop`, `z-index: -10`) in the
opacities, and `body`'s colour matches its bottom so mobile overscroll shows no seam. icon's navy and blue, and `body`'s colour matches its bottom so mobile overscroll shows no seam.
24. **The UI was deliberately made quieter.** One accent colour (`accent-*`), a desaturated 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 `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 seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry
@ -784,6 +787,12 @@ Things I chose while building, with the reasoning:
`user-scalable=no` for an app-like feel, and a mobile-only rule keeps every input and textarea at `user-scalable=no` for an app-like feel, and a mobile-only rule keeps every input and textarea at
16px so iOS Safari's focus auto-zoom never fires (iOS ignores `user-scalable=no` for that). 16px so iOS Safari's focus auto-zoom never fires (iOS ignores `user-scalable=no` for that).
Desktop keeps the designed 12–14px field sizes. 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 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 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`). support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision

View file

@ -11,7 +11,7 @@ export const metadata = {
export default function CreatePage() { export default function CreatePage() {
return ( return (
<ApiKeyGate> <ApiKeyGate>
<div className="flex min-h-dvh bg-[var(--page)]"> <div className="flex min-h-dvh">
<Sidebar /> <Sidebar />
<SidebarOffset /> <SidebarOffset />
<MobileSidebar /> <MobileSidebar />

View file

@ -4,30 +4,30 @@
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@theme { @theme {
/* Neutral scale (a whisper of cool) — the ChatGPT-ish workhorse. ------- */ /* Navy-tinted neutral scale — sampled from the app icon. ---------------- */
--color-frost-50: #f8f8f9; --color-frost-50: #f5f8fd;
--color-frost-100: #f1f1f3; --color-frost-100: #eaf0fa;
--color-frost-200: #e4e4e7; --color-frost-200: #d9e3f3;
--color-frost-300: #cfcfd4; --color-frost-300: #c2cfe4;
--color-frost-400: #a3a3aa; --color-frost-400: #92a3c0;
--color-frost-500: #7a7a82; --color-frost-500: #6a7c9b;
--color-frost-600: #5d5d64; --color-frost-600: #4e5f7d;
--color-frost-700: #47474d; --color-frost-700: #3c4b66;
--color-frost-800: #34343a; --color-frost-800: #2b3752;
--color-frost-900: #26262a; --color-frost-900: #1d2740;
--color-frost-950: #171719; --color-frost-950: #0e1526;
/* Accent: one confident blue for buttons and highlights. -------------- */ /* Accent: the icon's snowflake blue. ------------------------------------ */
--color-accent-50: #eef7fd; --color-accent-50: #eff7ff;
--color-accent-100: #d9edfa; --color-accent-100: #dbecff;
--color-accent-200: #b6dcf4; --color-accent-200: #bcdcff;
--color-accent-300: #83c3ea; --color-accent-300: #8fc6ff;
--color-accent-400: #4aa4dc; --color-accent-400: #59a8f7;
--color-accent-500: #2387c4; --color-accent-500: #3f95ea;
--color-accent-600: #186ca6; --color-accent-600: #2478d2;
--color-accent-700: #175687; --color-accent-700: #1d61ab;
--color-accent-800: #18496f; --color-accent-800: #1c518b;
--color-accent-900: #183e5d; --color-accent-900: #1b4473;
/* Aurora hues used only for the frosted backdrop wash. ----------------- */ /* Aurora hues used only for the frosted backdrop wash. ----------------- */
--color-aurora-mint: #9fe3d0; --color-aurora-mint: #9fe3d0;
@ -81,74 +81,76 @@
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
:root { :root {
color-scheme: light; color-scheme: light;
--page: #ffffff; --page: #f4f7fd;
--sidebar: #f9f9f9; --sidebar: #e9eff9;
--surface: #ffffff; --surface: #ffffff;
--surface-raised: #ffffff; --surface-raised: #ffffff;
--card-hover-bg: #fafafa; --surface-strong: #edf2fa;
--surface-sunken: rgb(13 13 13 / 0.05); --card-hover-bg: #f7faff;
--hairline: #e5e5e5; --surface-sunken: rgb(14 21 40 / 0.05);
--card-border: #e2e2e2; --hairline: #dce4f1;
--sidebar-divider: #e2e2e2; --card-border: #d7e0ef;
--hairline-soft: rgb(13 13 13 / 0.07); --sidebar-divider: #d7e0ef;
--glass-tint: rgb(255 255 255 / 0.6); --hairline-soft: rgb(14 21 40 / 0.07);
--text: #0d0d0d; /* 17.6:1 on #ffffff */ --glass-tint: rgb(255 255 255 / 0.55);
--text-muted: #5d5d5d; /* 7.4:1 */ --text: #0e1526; /* 16.9:1 on #f4f7fd */
--text-subtle: #6b6b6b; /* 5.4:1 */ --text-muted: #4d5c78; /* 6.3:1 */
--hover: rgb(13 13 13 / 0.05); --text-subtle: #5a6a86; /* 5.1:1 */
--sidebar-top: #fbfbfc; --hover: rgb(14 21 40 / 0.05);
--sidebar-bottom: #f6f6f7; --sidebar-top: #eef3fb;
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 18px -12px rgb(13 13 13 / 0.38); --sidebar-bottom: #e5edf9;
/* API-key gate: near-white stage with soft blank glows, tinted input. */ --sidebar-edge: 0 1px 0 var(--sidebar-divider), 6px 0 18px -12px rgb(14 21 40 / 0.35);
--gate-base: #f6f6f8; /* API-key gate: cool near-white stage with soft blue glows, tinted input. */
--gate-glow-a: rgb(84 102 130 / 0.22); --gate-base: #eef3fb;
--gate-glow-b: rgb(124 138 160 / 0.18); --gate-glow-a: rgb(90 150 240 / 0.25);
--gate-glow-b: rgb(140 150 240 / 0.2);
--gate-card: #ffffff; --gate-card: #ffffff;
--gate-card-border: #e7e7e9; --gate-card-border: #dde5f2;
--gate-callout-bg: #f2f2f5; --gate-callout-bg: #eef3fa;
--gate-callout-border: #cfcfd6; --gate-callout-border: #c9d5e8;
--field: #f3f3f4; --field: #eef2f9;
--field-border: #e2e2e4; --field-border: #dbe3f0;
--shadow-gate: 0 28px 70px -36px rgb(13 13 13 / 0.3), 0 1px 2px rgb(13 13 13 / 0.05); --shadow-gate: 0 28px 70px -36px rgb(14 21 40 / 0.3), 0 1px 2px rgb(14 21 40 / 0.05);
/* Install sheet: light variant (dark is the default, see .dark). */ /* Install sheet: light variant (dark is the default, see .dark). */
--install-sheet: #ffffff; --install-sheet: #ffffff;
--install-card: #f7f7f8; --install-card: #f7f7f8;
--install-chip: #f2f2f2; --install-chip: #f2f2f2;
--install-border: #e0e0e0; --install-border: #e0e0e0;
--shadow-card: 0 1px 2px rgb(13 13 13 / 0.06), 0 1px 3px rgb(13 13 13 / 0.04); --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(13 13 13 / 0.05), 0 5px 14px -6px rgb(13 13 13 / 0.12); --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(13 13 13 / 0.2), 0 2px 6px rgb(13 13 13 / 0.07); --shadow-card-hover: 0 12px 30px -12px rgb(14 21 40 / 0.2), 0 2px 6px rgb(14 21 40 / 0.07);
} }
.dark { .dark {
color-scheme: dark; color-scheme: dark;
--page: #212121; --page: #0e1424;
--sidebar: #171717; --sidebar: #0a0f1c;
--surface: #2f2f2f; --surface: #171f33;
--surface-raised: #383838; --surface-raised: #1e2740;
--card-hover-bg: #383838; --surface-strong: #202a42;
--surface-sunken: rgb(255 255 255 / 0.08); --card-hover-bg: #1e2740;
--hairline: rgb(255 255 255 / 0.14); --surface-sunken: rgb(255 255 255 / 0.07);
--card-border: var(--hairline); --hairline: rgb(255 255 255 / 0.12);
--sidebar-divider: var(--hairline); --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-soft: rgb(255 255 255 / 0.08);
--glass-tint: rgb(255 255 255 / 0.08); --glass-tint: rgb(255 255 255 / 0.06);
--text: #ececec; /* 12.9:1 on #212121 */ --text: #e9eefb; /* 15.4:1 on #0e1424 */
--text-muted: #b4b4b4; /* 7.6:1 */ --text-muted: #b7c3dc; /* 10.4:1 */
--text-subtle: #a0a0a0; /* 6.0:1 */ --text-subtle: #94a4c2; /* 7.3:1 */
--hover: rgb(255 255 255 / 0.07); --hover: rgb(255 255 255 / 0.07);
--sidebar-top: #181818; --sidebar-top: #0b1120;
--sidebar-bottom: #141414; --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.75);
/* API-key gate: near-black stage, monochrome glows, raised input. */ /* API-key gate: navy stage with the icon's blue glows, raised input. */
--gate-base: #0f0f0f; --gate-base: #0a0f1c;
--gate-glow-a: rgb(255 255 255 / 0.17); --gate-glow-a: rgb(70 140 235 / 0.2);
--gate-glow-b: rgb(255 255 255 / 0.12); --gate-glow-b: rgb(120 120 235 / 0.14);
--gate-card: #1a1a1a; --gate-card: #131a2c;
--gate-card-border: rgb(255 255 255 / 0.08); --gate-card-border: rgb(255 255 255 / 0.1);
--gate-callout-bg: rgb(255 255 255 / 0.07); --gate-callout-bg: rgb(255 255 255 / 0.06);
--gate-callout-border: rgb(255 255 255 / 0.22); --gate-callout-border: rgb(255 255 255 / 0.2);
--field: #242425; --field: #1b2438;
--field-border: rgb(255 255 255 / 0.1); --field-border: rgb(255 255 255 / 0.1);
--shadow-gate: 0 30px 80px -40px rgb(0 0 0 / 0.85), 0 1px 2px rgb(0 0 0 / 0.4); --shadow-gate: 0 30px 80px -40px rgb(0 0 0 / 0.85), 0 1px 2px rgb(0 0 0 / 0.4);
/* Install sheet: layered dark surfaces (default theme for the sheet). */ /* Install sheet: layered dark surfaces (default theme for the sheet). */
@ -161,22 +163,22 @@
--shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3); --shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
} }
/* Backdrop: a whisper of cool over a neutral base (never a coloured surface). */ /* Backdrop: the icon's navy base and blue wash, seen through frosted glass. */
:root { :root {
--bd-sky: rgb(150 200 240 / 0.07); --bd-sky: rgb(110 175 245 / 0.2);
--bd-lilac: rgb(188 178 236 / 0.05); --bd-lilac: rgb(145 145 240 / 0.14);
--bd-mint: rgb(150 220 202 / 0.05); --bd-mint: rgb(110 210 230 / 0.12);
--bd-top: #fdfdfd; --bd-top: #f8fafe;
--bd-mid: #fafafa; --bd-mid: #f2f6fd;
--bd-bottom: #f7f7f7; --bd-bottom: #ecf1fb;
} }
.dark { .dark {
--bd-sky: rgb(74 164 220 / 0.05); --bd-sky: rgb(60 130 230 / 0.14);
--bd-lilac: rgb(160 140 235 / 0.04); --bd-lilac: rgb(110 115 235 / 0.1);
--bd-mint: rgb(88 201 173 / 0.04); --bd-mint: rgb(70 180 215 / 0.08);
--bd-top: #232323; --bd-top: #111a2e;
--bd-mid: #212121; --bd-mid: #0e1424;
--bd-bottom: #1e1e1e; --bd-bottom: #0a101d;
} }
html, html,
@ -280,14 +282,14 @@ body {
} }
.dark .app-backdrop::before, .dark .app-backdrop::before,
.dark .app-backdrop::after { .dark .app-backdrop::after {
opacity: 0.16; opacity: 0.3;
} }
@layer components { @layer components {
/* Glass primitives ------------------------------------------------------ */ /* Glass primitives ------------------------------------------------------ */
.glass-panel { .glass-panel {
@apply backdrop-blur-xl; @apply backdrop-blur-xl backdrop-saturate-150;
background: var(--surface); background: color-mix(in oklab, var(--surface) 82%, transparent);
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
@ -323,7 +325,8 @@ body {
.card { .card {
border-radius: 1.125rem; /* 18px */ border-radius: 1.125rem; /* 18px */
background: var(--surface); @apply backdrop-blur-xl backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 78%, transparent);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
} }
@ -351,7 +354,7 @@ body {
.list-group { .list-group {
border-radius: 1rem; border-radius: 1rem;
@apply backdrop-blur-lg backdrop-saturate-150; @apply backdrop-blur-lg backdrop-saturate-150;
background: var(--surface); background: color-mix(in oklab, var(--surface) 82%, transparent);
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
overflow: hidden; overflow: hidden;
} }
@ -389,7 +392,7 @@ body {
} }
.dark .segmented > button[aria-pressed="true"], .dark .segmented > button[aria-pressed="true"],
.dark .segmented > button[aria-checked="true"] { .dark .segmented > button[aria-checked="true"] {
background: #383838; background: var(--surface-raised);
color: #fff; color: #fff;
} }
@ -420,9 +423,14 @@ body {
.dark .tint-peach { background: linear-gradient(140deg, rgb(230 160 118 / 0.26), rgb(230 160 118 / 0.1)); color: #fadfc9 } .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 } .dark .tint-rose { background: linear-gradient(140deg, rgb(230 130 150 / 0.26), rgb(230 130 150 / 0.1)); color: #fbd5dd }
/* Sidebar surface: a touch darker than the page, with a real edge. */ /* Sidebar surface: frosted navy glass with a real edge. */
.sidebar-surface { .sidebar-surface {
background: linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom)); @apply backdrop-blur-xl backdrop-saturate-150;
background: linear-gradient(
180deg,
color-mix(in oklab, var(--sidebar-top) 84%, transparent),
color-mix(in oklab, var(--sidebar-bottom) 84%, transparent)
);
border-right: 1px solid var(--sidebar-divider); border-right: 1px solid var(--sidebar-divider);
box-shadow: var(--sidebar-edge); box-shadow: var(--sidebar-edge);
} }
@ -450,27 +458,27 @@ body {
color: var(--text); color: var(--text);
} }
/* Shared CTA language: near-black pill in light mode, white pill in dark. */ /* Shared CTA language: the icon's blue, with a deeper fill in light mode. */
.btn-primary { .btn-primary {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
border-radius: 999px; border-radius: 999px;
background: #0d0d0d; background: #1d61ab;
color: #ffffff; color: #ffffff;
font-weight: 500; font-weight: 500;
box-shadow: 0 1px 2px rgb(13 13 13 / 0.18); box-shadow: 0 1px 2px rgb(14 21 40 / 0.22);
transition: background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; transition: background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
} }
.btn-primary:hover { background: #2f2f2f; } .btn-primary:hover { background: #2478d2; }
.btn-primary:active { transform: scale(0.985); } .btn-primary:active { transform: scale(0.985); }
.dark .btn-primary { .dark .btn-primary {
background: #ffffff; background: #59a8f7;
color: #0d0d0d; color: #081020;
box-shadow: none; box-shadow: none;
} }
.dark .btn-primary:hover { background: #e5e5e5; } .dark .btn-primary:hover { background: #7bbaff; }
.btn-secondary { .btn-secondary {
display: inline-flex; display: inline-flex;

View file

@ -20,14 +20,15 @@ export const metadata: Metadata = {
}, },
icons: { icons: {
icon: [ icon: [
{ url: "/icons/favicon-32.png", sizes: "32x32", type: "image/png" }, // ?v=2 busts browser favicon caches after the icon redesign.
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, { url: "/icons/favicon-32.png?v=2", sizes: "32x32", type: "image/png" },
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, { url: "/icons/icon-192.png?v=2", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512.png?v=2", sizes: "512x512", type: "image/png" },
], ],
apple: [ apple: [
{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }, { url: "/icons/apple-touch-icon.png?v=2", sizes: "180x180" },
{ url: "/icons/apple-touch-icon-167.png", sizes: "167x167" }, { url: "/icons/apple-touch-icon-167.png?v=2", sizes: "167x167" },
{ url: "/icons/apple-touch-icon-152.png", sizes: "152x152" }, { url: "/icons/apple-touch-icon-152.png?v=2", sizes: "152x152" },
], ],
}, },
other: { other: {
@ -48,8 +49,8 @@ export const viewport: Viewport = {
userScalable: false, userScalable: false,
viewportFit: "cover", viewportFit: "cover",
themeColor: [ themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f8fafc" }, { media: "(prefers-color-scheme: light)", color: "#f4f7fd" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0f13" }, { media: "(prefers-color-scheme: dark)", color: "#0e1424" },
], ],
}; };

View file

@ -14,8 +14,8 @@ export default function manifest(): MetadataRoute.Manifest {
scope: "/", scope: "/",
display: "standalone", display: "standalone",
orientation: "portrait", orientation: "portrait",
background_color: "#f8fafc", background_color: "#0e1424",
theme_color: "#f8fafc", theme_color: "#0e1424",
categories: ["education", "productivity"], categories: ["education", "productivity"],
icons: [ icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },

View file

@ -7,7 +7,7 @@ import { TabBar } from "@/components/TabBar";
export default function HomePage() { export default function HomePage() {
return ( return (
<ApiKeyGate> <ApiKeyGate>
<div className="flex min-h-dvh bg-[var(--page)]"> <div className="flex min-h-dvh">
<Sidebar /> <Sidebar />
<SidebarOffset /> <SidebarOffset />
<MobileSidebar /> <MobileSidebar />

View file

@ -11,7 +11,6 @@ import {
EyeOffIcon, EyeOffIcon,
MoonIcon, MoonIcon,
QuestionIcon, QuestionIcon,
SnowflakeIcon,
SunIcon, SunIcon,
XIcon, XIcon,
} from "@/components/icons"; } from "@/components/icons";
@ -233,12 +232,13 @@ function GateScreen() {
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
className="my-auto w-full max-w-[420px] rounded-[24px] border border-[var(--gate-card-border)] bg-[var(--gate-card)] p-6 px-7 text-center shadow-[var(--shadow-gate)] sm:p-8" className="my-auto w-full max-w-[420px] rounded-[24px] border border-[var(--gate-card-border)] bg-[var(--gate-card)] p-6 px-7 text-center shadow-[var(--shadow-gate)] sm:p-8"
> >
<span {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden aria-hidden
className="mx-auto grid size-14 place-items-center rounded-[16px] bg-[var(--text)] text-[var(--page)] dark:border dark:border-[var(--field-border)] dark:bg-[var(--field)] dark:text-[var(--text)]" className="mx-auto size-14 rounded-[16px] shadow-[var(--shadow-card)]"
> />
<SnowflakeIcon className="size-7" />
</span>
<h1 className="mt-6 text-[24px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[28px]"> <h1 className="mt-6 text-[24px] font-medium leading-tight tracking-[-0.01em] text-[var(--text)] sm:text-[28px]">
Connect your Ollama key Connect your Ollama key

View file

@ -3,7 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { GearIcon, MenuIcon, SnowflakeIcon } from "@/components/icons"; import { GearIcon, MenuIcon } from "@/components/icons";
import { SettingsDialog } from "@/components/SettingsDialog"; import { SettingsDialog } from "@/components/SettingsDialog";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { sidebarOpen } from "@/lib/nav"; import { sidebarOpen } from "@/lib/nav";
@ -47,12 +47,13 @@ export function AppHeader({ className, compact = false }: { className?: string;
<MenuIcon className="size-5" /> <MenuIcon className="size-5" />
</button> </button>
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label="Freezmo home"> <Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label="Freezmo home">
<span {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden aria-hidden
className="grid size-8 place-items-center rounded-[0.6rem] bg-[var(--text)] text-[var(--page)] transition-transform group-hover:scale-[1.04]" className="size-8 rounded-[0.6rem] transition-transform group-hover:scale-[1.04]"
> />
<SnowflakeIcon className="size-[18px]" />
</span>
<span className="flex items-baseline gap-2"> <span className="flex items-baseline gap-2">
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]"> <span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
Freezmo Freezmo

View file

@ -12,7 +12,6 @@ import {
LayersIcon, LayersIcon,
MoonIcon, MoonIcon,
PlusIcon, PlusIcon,
SnowflakeIcon,
SunIcon, SunIcon,
} from "@/components/icons"; } from "@/components/icons";
import { useTheme } from "@/components/ThemeToggle"; import { useTheme } from "@/components/ThemeToggle";
@ -110,12 +109,13 @@ function NavContent({
aria-label="Freezmo home" aria-label="Freezmo home"
title="Freezmo" title="Freezmo"
> >
<span {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden aria-hidden
className="grid size-7 shrink-0 place-items-center rounded-lg bg-[var(--text)] text-[var(--page)]" className="size-7 shrink-0 rounded-lg shadow-[0_1px_3px_rgb(14_21_40/0.35)]"
> />
<SnowflakeIcon className="size-4" />
</span>
{!collapsed ? ( {!collapsed ? (
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span> <span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span>
) : null} ) : null}

View file

@ -2,7 +2,6 @@
import { forwardRef } from "react"; import { forwardRef } from "react";
import { SnowflakeIcon } from "@/components/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success"; type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success";
@ -135,12 +134,17 @@ export function EmptyState({
}) { }) {
return ( return (
<div className="animate-rise flex flex-col items-center px-6 py-6 text-center sm:py-24"> <div className="animate-rise flex flex-col items-center px-6 py-6 text-center sm:py-24">
{icon ? (
<span <span
className="mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-subtle)]" className="mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-subtle)]"
aria-hidden aria-hidden
> >
{icon ?? <SnowflakeIcon className="size-6" />} {icon}
</span> </span>
) : (
/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */
<img src="/icons/icon-192.png" alt="" aria-hidden className="mb-4 size-12 rounded-2xl" />
)}
<h2 className="text-[17px] font-semibold tracking-[-0.01em] text-[var(--text)]">{title}</h2> <h2 className="text-[17px] font-semibold tracking-[-0.01em] text-[var(--text)]">{title}</h2>
{children ? ( {children ? (
<p className="mt-2 max-w-md text-sm leading-relaxed text-[var(--text-muted)]">{children}</p> <p className="mt-2 max-w-md text-sm leading-relaxed text-[var(--text-muted)]">{children}</p>