From 786d6cf1aec48dcaec4be456d65ab99b988fb74b Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Wed, 14 Oct 2026 21:32:50 +0000 Subject: [PATCH] Chat chrome: one gutter, one radius scale, one even glass recipe The header and composer were full-bleed bands (flush to the top/right/bottom viewport edges, composer corners off-screen) next to a rail that floats with a 12px margin. The new --layout-gutter (12px) spaces the rail, header, empty-state card and composer identically on every side; --radius-panel/-control/-chip (-pill) replace the ad-hoc radii across the shared classes, so every panel is rounded on all four corners and shared controls match in every theme. The desktop-only ::after hairlines and the left-corner-only radius are gone. Aero: the rail and the chat chrome (answers included) share one even recipe - the wallpaper extra-blurred into a hue-free wash (background-glass.webp) under a uniform ~50% white veil and a whisper of gloss. Measured within ~10 RGB units across the rail top->bottom and the card/composer left->right; the blue->green smear and the teal composer are gone. The composer's field is a clean even tile with no wallpaper gradient inside it. Chat fixes: send is a filled primary tile shaped exactly like attach (dimmed while the input is empty), suggestion/context chips use the shared .chip token (40/44px target, visible rim, full-contrast text, raw model id in the tooltip), the AI note reads at 12px --text-muted (.ai-disclaimer), the selected sidebar row carries the accent border + accent icon, the theme toggle is back in Aero (the moon flips to Dark; Settings brings Aero back), and the empty state is centered exactly between header and composer. Outside CSS: the wallpaper's top-left HUD-circle decoration (it peeked through the 12px gutters on wide windows) was healed out of background.webp and background-blur.webp; the orbs/bubbles ride one masked .ice-floats layer that fades them under the chrome bands and the rail strip, so none is sliced by a panel edge (still parked under reduced motion). Version 0.6.0, SW -> freezmo-v82. Tests: chat suite pins the new geometry (12px gutter, 24px corners, even wash on all four panels, send-vs-attach shape, chips/input radii); mobile pins the phone chrome and the Aero toggle; bubbles pins the floats layer + its reduced-motion opt-out. --- README.md | 33 +++ app/globals.css | 364 ++++++++++++++++++----------- components/AiDisclaimer.tsx | 6 +- components/IceBackdrop.tsx | 52 +++-- components/Sidebar.tsx | 49 ++-- components/ThemeToggle.tsx | 11 +- components/chat/ChatScreen.tsx | 29 ++- components/chat/Composer.tsx | 28 +-- components/chat/ContextPicker.tsx | 6 +- components/chat/CopyIconButton.tsx | 2 +- components/chat/HistoryPanel.tsx | 14 +- components/chat/MessageRow.tsx | 15 +- components/chat/ModelPicker.tsx | 8 +- package.json | 2 +- public/aero/background-blur.webp | Bin 6294 -> 12716 bytes public/aero/background-glass.webp | Bin 0 -> 1768 bytes public/aero/background.webp | Bin 206110 -> 221142 bytes public/sw.js | 2 +- tests/bubbles-test.mjs | 31 ++- tests/chat-test.mjs | 133 ++++++++--- tests/mobile-test.mjs | 67 +++++- 21 files changed, 562 insertions(+), 290 deletions(-) create mode 100644 public/aero/background-glass.webp diff --git a/README.md b/README.md index 7cb72e6..eca681d 100644 --- a/README.md +++ b/README.md @@ -1423,6 +1423,39 @@ Things I chose while building, with the reasoning: (measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as "cut off next to the sidebar". +91. **The chat chrome floats: one gutter, one radius scale, one glass recipe.** The header and + the composer were full-bleed bands inside the main pane — flush to the viewport's top, right + and bottom edges, with the composer's bottom corners cropped off-screen — while the rail + floated with a 12px margin. `--layout-gutter` (12px, the rail's own inset) now spaces the + rail, the header, the empty-state card and the composer identically on all four sides, and + the radius scale (`--radius-panel` 24px, `--radius-control` 14px, `--radius-chip` 12px, + `--radius-pill`) replaced the ad-hoc values across the shared classes (`.card`, `.glass-panel`, + `.sidebar-surface`, buttons, nav rows, `.segmented`, chips), so every panel is rounded on all + four corners and shared controls match in every theme. The desktop-only `::after` hairlines + and the left-corner-only radius of decision 87 are gone. + + In Aero the rail and the chat chrome (answers included) share **one even recipe**: the + wallpaper, extra-blurred into a hue-free wash (`background-glass.webp`), under a uniform + ~50% white veil and a whisper of gloss. Measured: the rail top→bottom and the card/composer + left→right stay within ~10 RGB units (the old rail ran clear blue into a muddy green smear, + and the composer picked up the river's teal). Chat-specific fixes: **send** is a filled + primary tile shaped exactly like attach (dimmed while the input is empty — it used to be a + pale outline that read as disabled), the suggestion/context **chips** use the shared `.chip` + token (40px target, 44px on phones, visible rim, full-contrast text, the raw model id in the + tooltip), the AI note reads at 12px `--text-muted` (`.ai-disclaimer`), the sidebar's selected + row carries the accent border + accent icon (it looked like one more tinted action button), + the **theme toggle** is back in Aero — the moon flips to Dark, the Settings picker brings Aero + back — and the empty state is centered exactly between the header and the composer (the + trailing spacer only exists under a conversation now). Three fixes live outside CSS: the + wallpaper's **HUD-circle decoration** in the top-left corner (it peeked through the 12px + gutters on wide windows and read as a glitch) was healed out of `background.webp` and + `background-blur.webp`; the orbs and bubbles ride one masked `.ice-floats` layer that fades + them out under the chrome bands and the rail's strip, so none is sliced by a panel edge (still + parked under reduced motion); and the panel shadows/highlights come from the shared glass + tokens. The chat suite pins the geometry (12px gutter on all sides, 24px corners, the even + wash on all four panels, send-vs-attach shape, chips/input radii) and mobile pins the phone + chrome plus the Aero toggle. + 90. **Copy pass on the shell (`lib/i18n-dict.ts`).** A user-authored wording pass: the library heading is now "Your freezer" (matching the Czech "Tvůj mrazák"), the key gate's subtitle speaks about grades ("První krok k dobrým známkám"), and several Czech strings read more naturally ("Schovat / diff --git a/app/globals.css b/app/globals.css index b5fbdf6..7d348ea 100644 --- a/app/globals.css +++ b/app/globals.css @@ -100,6 +100,15 @@ --hover: rgb(12 32 62 / 0.05); --focus: #0ea5e9; + /* Shared layout scale: one gutter for every floating panel and one radius + per role, so the rail, the header, the composer and the cards line up in + every theme — only the surface treatment differs. */ + --layout-gutter: 0.75rem; /* 12px — the same inset the rail floats with */ + --radius-panel: 1.5rem; /* 24px — floating panels and cards */ + --radius-control: 0.875rem; /* 14px — buttons, inputs, tiles */ + --radius-chip: 0.75rem; /* 12px — chips, nav rows, small controls */ + --radius-pill: 999px; /* fully rounded */ + /* Frosted ice glass — one shared recipe for every large surface. */ --glass-bg: rgb(255 255 255 / 0.55); /* A cool rim instead of near-white: white-on-white made light-mode card @@ -112,8 +121,8 @@ than the cards: at 0.55 the muted text under it measured ~2.8:1. */ --glass-sheet-bg: rgb(255 255 255 / 0.93); --glass-fallback: rgb(247 251 255 / 0.97); /* no backdrop-filter */ - --nav-active-bg: rgb(255 255 255 / 0.75); - --nav-active-border: rgb(56 189 248 / 0.5); + --nav-active-bg: rgb(219 238 254 / 0.95); + --nav-active-border: rgb(56 189 248 / 0.65); /* API-key gate: cool near-white stage with soft blue glows, tinted input. */ --gate-card: rgb(255 255 255 / 0.62); --gate-callout-bg: rgb(255 255 255 / 0.55); @@ -160,8 +169,8 @@ --glass-solid-bg: rgb(255 255 255 / 0.1); --glass-sheet-bg: rgb(17 27 46 / 0.94); --glass-fallback: rgb(16 26 45 / 0.97); - --nav-active-bg: rgb(56 189 248 / 0.14); - --nav-active-border: rgb(56 189 248 / 0.4); + --nav-active-bg: rgb(56 189 248 / 0.18); + --nav-active-border: rgb(56 189 248 / 0.55); /* API-key gate: navy stage with the icon's blue glows, raised input. */ --gate-card: rgb(255 255 255 / 0.06); --gate-callout-bg: rgb(255 255 255 / 0.06); @@ -235,8 +244,8 @@ --glass-solid-bg: rgb(232 248 255 / 0.9); --glass-sheet-bg: rgb(232 246 255 / 0.94); --glass-fallback: rgb(226 242 253 / 0.97); - --nav-active-bg: rgb(255 255 255 / 0.85); - --nav-active-border: rgb(14 165 233 / 0.7); + --nav-active-bg: rgb(255 255 255 / 0.5); + --nav-active-border: rgb(14 165 233 / 0.75); --gate-card: rgb(226 244 255 / 0.7); --gate-callout-bg: rgb(255 255 255 / 0.6); --gate-callout-border: rgb(56 189 248 / 0.45); @@ -263,7 +272,7 @@ in public/aero/). Only the moving bubbles are drawn on top; the image already has grass, sun, water and clouds. */ .aero .ice-scene { - background-image: url("/aero/background.webp?v=3"); + background-image: url("/aero/background.webp?v=4"); background-size: cover; background-position: center 42%; background-repeat: no-repeat; @@ -417,7 +426,7 @@ --surface-strong: rgb(255 255 255 / 0.23); --surface-sunken: rgb(255 255 255 / 0.17); --track: rgb(255 255 255 / 0.38); - --nav-active-bg: rgb(255 255 255 / 0.27); + --nav-active-bg: rgb(255 255 255 / 0.5); } /* Liquid glass — one recipe for every surface, so the rail, the cards, the @@ -467,7 +476,7 @@ rgb(255 255 255 / 0.09) 100% ), linear-gradient(rgb(255 255 255 / 0.16), rgb(255 255 255 / 0.16)), - url("/aero/background-blur.webp?v=3"); + url("/aero/background-blur.webp?v=4"); background-size: 100% 100%, 100% 100%, cover; background-position: 0 0, 0 0, center 42%; background-attachment: scroll, scroll, fixed; @@ -555,20 +564,15 @@ /* Chat: the longest text in the app sits straight on the wallpaper. The assistant answers are deliberately bubble-less in the flat themes, and the user bubble's thin tint washes out over the bright parts of the scene — in - Aero both get a real pane (a stronger veil than the shared glass, so an - answer reads as its own bubble). */ + Aero both get a real pane: the same even wash as the chrome, a touch more + opaque (this is where the reading happens), so an answer reads as its own + bubble and its colour does not drift with the photo behind it. */ .aero .chat-bubble, .aero .chat-answer { - border-color: rgb(255 255 255 / 0.7); + border-color: rgb(255 255 255 / 0.85); background-image: - linear-gradient( - 180deg, - rgb(255 255 255 / 0.6) 0%, - rgb(255 255 255 / 0.44) 46%, - rgb(255 255 255 / 0.4) 53%, - rgb(255 255 255 / 0.52) 100% - ), - url("/aero/background-blur.webp?v=3"); + linear-gradient(rgb(255 255 255 / 0.6), rgb(255 255 255 / 0.6)), + url("/aero/background-glass.webp?v=4"); background-size: 100% 100%, cover; background-position: 0 0, center 42%; background-attachment: scroll, fixed; @@ -576,9 +580,9 @@ box-shadow: 0 16px 34px -22px rgb(2 62 110 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.9); } .aero .chat-answer { - border: 1px solid rgb(255 255 255 / 0.7); - border-radius: 1.25rem; - border-bottom-left-radius: 0.6rem; + border: 1px solid rgb(255 255 255 / 0.85); + border-radius: var(--radius-panel); + border-bottom-left-radius: var(--radius-chip); padding: 0.7rem 0.95rem; } @@ -593,7 +597,7 @@ rgb(255 255 255 / 0.08) 53%, rgb(255 255 255 / 0.13) 100% ), - url("/aero/background-blur.webp?v=3"); + url("/aero/background-blur.webp?v=4"); background-size: 100% 100%, cover; background-position: 0 0, center 42%; background-attachment: scroll, fixed; @@ -633,6 +637,15 @@ background-image: none; background-color: rgb(255 255 255 / 0.55); } + /* The chat chrome and the rail keep one even, a little stronger tint on + touch: without the baked blur the flat fill has to carry the surface on + its own, or the photo underneath tints it unevenly again. */ + .aero .chat-header, + .aero .chat-empty, + .aero .chat-composer-band { + background-image: none; + background-color: rgb(255 255 255 / 0.55); + } .aero .tabbar { background-image: none; } @@ -665,7 +678,7 @@ rgb(255 255 255 / 0.32) 53%, rgb(255 255 255 / 0.44) 100% ), - url("/aero/background-blur.webp?v=3"); + url("/aero/background-blur.webp?v=4"); background-size: 100% 100%, cover; background-position: 0 0, center 42%; background-attachment: scroll, fixed; @@ -680,7 +693,7 @@ rgb(255 255 255 / 0.32) 53%, rgb(255 255 255 / 0.44) 100% ), - url("/aero/background-blur.webp?v=3"); + url("/aero/background-blur.webp?v=4"); background-size: 100% 100%, cover; background-position: 0 0, center 42%; background-attachment: scroll, fixed; @@ -689,85 +702,41 @@ color: var(--text); } -/* Chat header: the same glass as the cards, a touch more fill — its title and - scope line sit directly over the sky. At lg its divider is the inset hairline - the desktop block below draws (::after), so it never hits the seam. */ -.aero .chat-header { - background-color: rgb(232 248 255 / 0.4); - border-bottom: 1px solid rgb(255 255 255 / 0.6); -} -.aero .chat-header::after { - background: rgb(255 255 255 / 0.6); +/* Chat chrome and the rail: one even frosted recipe. The pre-blurred, + extra-soft wash of the wallpaper (background-glass) sits under a uniform + white veil, so the surface colour cannot drift with the photo behind it — + the rail used to run from clear blue into a muddy green smear, the composer + picked up the river's teal and the header a saturated stripe of sky. */ +.aero .sidebar-surface, +.aero .chat-header, +.aero .chat-empty, +.aero .chat-composer-band { + background-image: + linear-gradient( + 180deg, + rgb(255 255 255 / 0.3) 0%, + rgb(255 255 255 / 0.1) 46%, + rgb(255 255 255 / 0.05) 53%, + rgb(255 255 255 / 0.12) 100% + ), + linear-gradient(rgb(255 255 255 / 0.46), rgb(255 255 255 / 0.46)), + url("/aero/background-glass.webp?v=4"); + background-size: 100% 100%, 100% 100%, cover; + background-position: 0 0, 0 0, center 42%; + background-attachment: scroll, scroll, fixed; + background-repeat: no-repeat, no-repeat, no-repeat; } -/* The chat composer: one flat frosted panel for the input, the chips and the - note — instead of each of them floating on the wallpaper. */ -.aero .chat-composer { - background-color: rgb(232 248 255 / 0.55); - border-top: 1px solid rgb(255 255 255 / 0.65); - box-shadow: 0 -16px 36px -26px rgb(2 62 110 / 0.5); -} -.aero .chat-composer::after { - background: rgb(255 255 255 / 0.65); -} - -/* Desktop: the chat chrome ends at the rail with a rounded corner. The header - and the composer are full-bleed bands inside the main pane, which starts - right at the sidebar; a straight vertical edge there read as a hard cut (the - 12px gutter showed the raw scene and the bands' fill/blur made it a color - step at the seam). Both bands now stop at the seam with the rail card's own - 1.5rem radius on their left end, so the ending reads as deliberate, and their - hairlines span the content box instead of touching the seam. Phones keep the - plain full-width borders. */ -@media (min-width: 1024px) { - .chat-header { - border-bottom: 0; - border-start-start-radius: 1.5rem; - border-end-start-radius: 1.5rem; - } - .aero .chat-header { - border-bottom: 0; - } - .chat-header::after { - content: ""; - position: absolute; - left: 1.5rem; - right: 1rem; - bottom: 0; - height: 1px; - background: var(--glass-border); - } - .chat-composer, - .chat-composer-band { - border-start-start-radius: 1.5rem; - border-end-start-radius: 1.5rem; - } - .chat-composer-band { - border-top: 0; - } - .aero .chat-composer { - border-top: 0; - } - .chat-composer::after { - content: ""; - position: absolute; - left: 2.5rem; - right: 2.5rem; - top: 0; - height: 1px; - background: var(--glass-border); - } -} - -/* The chat welcome (subtitle + suggestions): the same frosted glass as the - cards (the shared recipe above), stretched flush with the header and the - composer — a panel floating 40px inside the scene looked cut off. */ +/* The chat welcome (title, subtitle, suggestion chips): a card like the + library's, stretched across the conversation column so it lines up with the + floating header above and composer below it. */ .aero .chat-empty { margin-inline: -1rem; - border-radius: 1.5rem; - border: 1px solid rgb(255 255 255 / 0.5); - background-color: rgb(255 255 255 / 0.32); padding: 1.35rem 1rem 1.1rem; + border-radius: var(--radius-panel); + border: 1px solid var(--glass-border); + background-color: var(--glass-bg); + box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight); } @media (min-width: 1024px) { .aero .chat-empty { @@ -776,6 +745,24 @@ } } +/* The column floats inside the main pane with the shared gutter; on desktop + the rail already carries its own 12px right-hand gutter, so the column + drops its left padding — every gap between the panels is then the same + --layout-gutter. (Unlayered on purpose: it must beat the Tailwind + `p-[var(--layout-gutter)]` utility.) */ +@media (min-width: 1024px) { + .chat-column { padding-left: 0; } +} + +/* The composer's field: a clean, even glass tile — no wallpaper gradient + inside it, just a lighter veil than the panel around it. */ +.aero .chat-composer-band textarea, +.aero .chat-composer-band input[type="search"] { + background-image: none; + background-color: rgb(255 255 255 / 0.6); + border-color: rgb(255 255 255 / 0.9); +} + /* Page headings that sit straight on the wallpaper. */ .aero .page-heading { display: inline-block; @@ -785,25 +772,10 @@ background-color: rgb(255 255 255 / 0.32); } -/* The rail's lower half sits over the grass; weight the glass a little below - the middle so the bottom rows keep the same contrast as the top. */ -.aero .sidebar-surface { - background-image: - linear-gradient(180deg, rgb(255 255 255 / 0) 42%, rgb(255 255 255 / 0.22) 100%), - linear-gradient( - 180deg, - rgb(255 255 255 / 0.15) 0%, - rgb(255 255 255 / 0.06) 46%, - rgb(255 255 255 / 0.05) 53%, - rgb(255 255 255 / 0.09) 100% - ), - linear-gradient(rgb(255 255 255 / 0.16), rgb(255 255 255 / 0.16)), - url("/aero/background-blur.webp?v=3"); - background-size: 100% 100%, 100% 100%, 100% 100%, cover; - background-position: 0 0, 0 0, 0 0, center 42%; - background-attachment: scroll, scroll, scroll, fixed; - background-repeat: no-repeat, no-repeat, no-repeat, no-repeat; -} +/* The rail used to weight its lower half (the grass read darker through the + glass) with a gradient that left a visible blue → green smear. It now uses + the same even veil as the chat chrome — see the shared recipe above — so + every row keeps the same contrast from top to bottom. */ /* Inside a sheet or dialog the backdrop is not the raw scene — a scrim and a more opaque sheet glass sit in between — so the viewport-aligned frosted @@ -976,6 +948,90 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { 100% { transform: translate3d(0, calc(-100vh - 100%), 0) scale(1.06); opacity: 0; } } +/* The rising orbs and bubbles are clipped to the scene, but the floating + glass panels (the rail, the chat header/composer, the tab bar) sit on top of + it — a bubble reaching one of those edges looked sliced by a straight line. + This layer keeps them inside the open middle band: they fade in below the + top chrome, out above the bottom chrome (the values come from where the + header and the composer actually end) and, on desktop, stay out of the rail + strip. Static under reduced motion: nothing moves there, and the parked + orbs keep their spots. */ +.ice-floats { + position: absolute; + inset: 0; + overflow: hidden; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + transparent 68px, + black 150px, + black calc(100% - 330px), + transparent calc(100% - 172px) + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + transparent 68px, + black 150px, + black calc(100% - 330px), + transparent calc(100% - 172px) + ); +} +@media (min-width: 640px) { + .ice-floats { + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + transparent 68px, + black 150px, + black calc(100% - 300px), + transparent calc(100% - 151px) + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + transparent 68px, + black 150px, + black calc(100% - 300px), + transparent calc(100% - 151px) + ); + } +} +@media (min-width: 1024px) { + /* …and the rail's strip (plus its gutter) stays clear too. */ + .ice-floats { + -webkit-mask-image: + linear-gradient( + to bottom, + transparent 0, + transparent 68px, + black 150px, + black calc(100% - 300px), + transparent calc(100% - 151px) + ), + linear-gradient(to right, transparent 0, transparent 260px, black 360px); + mask-image: + linear-gradient( + to bottom, + transparent 0, + transparent 68px, + black 150px, + black calc(100% - 300px), + transparent calc(100% - 151px) + ), + linear-gradient(to right, transparent 0, transparent 260px, black 360px); + -webkit-mask-composite: destination-in; + mask-composite: intersect; + } +} +@media (prefers-reduced-motion: reduce) { + .ice-floats { -webkit-mask-image: none !important; mask-image: none !important; } +} +html.reduce-motion .ice-floats { + -webkit-mask-image: none !important; + mask-image: none !important; +} + /* Frosted grain over the whole scene. */ .ice-grain { position: absolute; @@ -1104,7 +1160,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { .glass-panel { border: 1px solid var(--glass-border); - border-radius: 1.5rem; + border-radius: var(--radius-panel); } /* Nested glass: slightly more opaque than its parent, no extra blur. */ @@ -1139,13 +1195,13 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { } .card { - border-radius: 1.5rem; /* 24px */ + border-radius: var(--radius-panel); /* 24px */ border: 1px solid var(--glass-border); } .gate-card { border: 1px solid var(--glass-border); - border-radius: 1.75rem; /* 28px */ + border-radius: var(--radius-panel); } /* Blur is expensive: halve it on phones (floating glass only). */ @@ -1188,7 +1244,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { /* Inset grouped list ---------------------------------------------------- */ .list-group { - border-radius: 1rem; + border-radius: var(--radius-control); @apply backdrop-blur-lg backdrop-saturate-150; background: color-mix(in oklab, var(--surface) 82%, transparent); border: 1px solid var(--hairline); @@ -1198,19 +1254,53 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { border-top: 1px solid var(--hairline-soft); } +/* Shared chip ------------------------------------------------------------ + One control language for the chat's suggestion chips, the composer's + context/model chips and the small chips inside a message: a light glass + fill, a visible rim, full-contrast text and a real tap target (44px on + phones, like every other control). */ +.chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 2.5rem; + border-radius: var(--radius-chip); + border: 1px solid var(--glass-border); + background: var(--surface-strong); + color: var(--text); + font-weight: 600; + box-shadow: inset 0 1px 0 var(--glass-highlight); + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} +.chip:hover { + background: var(--hover); + border-color: var(--nav-active-border); +} +@media (max-width: 639px) { + .chip { min-height: 44px; } +} + +/* The AI note under a surface that shows model output -------------------- */ +.ai-disclaimer { + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); + text-align: center; +} + /* iOS segmented control ------------------------------------------------- */ .segmented { display: flex; gap: 2px; padding: 3px; - border-radius: 0.95rem; + border-radius: var(--radius-control); @apply backdrop-blur-sm; background: var(--surface-sunken); border: 1px solid var(--hairline); } .segmented > button { flex: 1; - border-radius: 0.75rem; + border-radius: var(--radius-chip); padding: 0.42rem 0.5rem; font-size: 0.875rem; font-weight: 600; @@ -1239,7 +1329,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { justify-content: center; gap: 0.375rem; min-height: 2.75rem; - border-radius: 0.75rem; + border-radius: var(--radius-chip); padding: 0.42rem 0.5rem; font-size: 0.875rem; font-weight: 600; @@ -1301,7 +1391,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { /* Sidebar surface: one tall inset glass panel. */ .sidebar-surface { border: 1px solid var(--glass-border); - border-radius: 1.5rem; + border-radius: var(--radius-panel); } /* Sidebar nav rows */ @@ -1310,7 +1400,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { align-items: center; gap: 0.625rem; width: 100%; - border-radius: 0.625rem; + border-radius: var(--radius-chip); padding: 0.5rem 0.625rem; font-size: 13.5px; font-weight: 500; @@ -1330,7 +1420,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { align-items: center; gap: 0.625rem; width: 100%; - border-radius: 0.75rem; + border-radius: var(--radius-chip); padding: 0.5rem 0.625rem; font-size: 13.5px; font-weight: 600; @@ -1347,7 +1437,15 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { .nav-row[aria-pressed="true"] { background: var(--nav-active-bg); color: var(--text); - box-shadow: inset 0 0 0 1px var(--nav-active-border), inset 0 1px 0 var(--glass-highlight); + font-weight: 600; + box-shadow: inset 0 0 0 1.5px var(--nav-active-border), inset 0 1px 0 var(--glass-highlight); +} +/* …and the icon carries the accent, so the selected row can never be mistaken + for one more tinted action button ("New deck", "New homework"). */ +.nav-row[aria-current="page"] svg, +.nav-row[aria-pressed="true"] svg { + color: var(--focus); + opacity: 1; } /* Shared CTA language: one ice gradient in *both* themes (it mirrors the @@ -1358,7 +1456,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { align-items: center; justify-content: center; gap: 0.5rem; - border-radius: 14px; + border-radius: var(--radius-control); background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%); color: #06111f; font-weight: 600; @@ -1389,7 +1487,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { align-items: center; justify-content: center; gap: 0.5rem; - border-radius: 14px; + border-radius: var(--radius-control); background: var(--surface-strong); color: var(--text); border: 1px solid var(--glass-border); @@ -1407,7 +1505,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { align-items: center; justify-content: center; gap: 0.5rem; - border-radius: 14px; + border-radius: var(--radius-control); background: transparent; color: var(--text-muted); font-weight: 500; diff --git a/components/AiDisclaimer.tsx b/components/AiDisclaimer.tsx index c4fd869..5019274 100644 --- a/components/AiDisclaimer.tsx +++ b/components/AiDisclaimer.tsx @@ -17,8 +17,10 @@ export function AiDisclaimer({ className, plain = false }: { className?: string; return ( // hint-line: Aero gives this its own glass chip (it sits on the wallpaper). // Where the note already sits on a glass panel (the chat composer), `plain` - // keeps it plain centered text like the other themes. -

+ // keeps it plain centered text like the other themes. The size and colour + // come from the shared .ai-disclaimer token, so it reads the same (and + // clears AA) in every theme. +

{t("common.aiDisclaimer")}

); diff --git a/components/IceBackdrop.tsx b/components/IceBackdrop.tsx index c87a0c7..f53d910 100644 --- a/components/IceBackdrop.tsx +++ b/components/IceBackdrop.tsx @@ -26,31 +26,33 @@ export function IceBackdrop() { - - - - - - {/* Aero-only bubbles: display:none unless the aero theme is on, so they - cost nothing in the other themes (see .ice-bubble in globals.css). */} - - - - - - - - - - - - - - - - - - +
+ + + + + + {/* Aero-only bubbles: display:none unless the aero theme is on, so they + cost nothing in the other themes (see .ice-bubble in globals.css). */} + + + + + + + + + + + + + + + + + + +
); diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 7c86f14..69ed9b7 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -285,31 +285,30 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) { {!collapsed ? {t("common.settings")} : null} - {/* Frutiger Aero has no light/dark flip (see the theme picker in - Settings), so this row goes away while it is on. */} - {theme === "aero" ? null : ( - - )} + {/* Light/dark flip — in Aero the moon jumps to Dark (the picker in + Settings brings Aero back). The row is shared by all three themes + now, so the rail always carries a theme control. */} + ); diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index ff800d1..2fa290d 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -7,18 +7,17 @@ import { useTheme } from "@/lib/theme"; import { cn } from "@/lib/utils"; /** - * The light/dark flip. Frutiger Aero has no such switch (the theme picker in - * Settings owns it), so this renders nothing while aero is on — the state - * comes from the shared store in lib/theme, so a change in Settings hides - * this immediately. + * The light/dark flip. Frutiger Aero keeps the same control in the same + * spot: it has no dark variant of its own, so from Aero the moon flips to + * Dark (the theme picker in Settings is how Aero comes back) — the toggle + * used to disappear there, which left the chat header and the rail with no + * theme control at all. */ export function ThemeToggle({ className, plain = false }: { className?: string; plain?: boolean }) { const { theme, toggle } = useTheme(); const { t } = useI18n(); const dark = theme === "dark"; - if (theme === "aero") return null; - return (

{title || t("chat.title")}

-

+

{scopeText} {seed ? ` · ${seed.label}` : ""}

@@ -605,20 +605,20 @@ export function ChatScreen() { onClick={() => setHistoryOpen(true)} aria-label={t("chat.history")} title={t("chat.history")} - className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden" + className="grid size-11 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden" > @@ -638,16 +638,16 @@ export function ChatScreen() { {messages.length === 0 ? (
- +

{t("chat.emptyTitle")}

-

+

{t("chat.emptyHint")}

{missingNotes ? ( -

+

{t("chat.oldDecksNote")}

) : null} @@ -658,7 +658,7 @@ export function ChatScreen() { type="button" disabled={!online || busy} onClick={() => send(suggestion)} - className="max-w-full rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-left text-[13px] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-55" + className="chip max-w-full px-3.5 py-2 text-left text-[13px] disabled:opacity-55" > {suggestion} @@ -697,10 +697,9 @@ export function ChatScreen() { ))} )} -
+ {messages.length > 0 ?
: null}
- {!pinned && messages.length > 0 ? ( @@ -101,7 +101,7 @@ export function Composer({ title={t("chat.attach")} disabled={attachBusy} onClick={() => fileInput.current?.click()} - className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60" + className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60" > {attachBusy ? : } @@ -132,7 +132,7 @@ export function Composer({ rows={1} placeholder={t("chat.placeholder")} aria-label={t("chat.placeholder")} - className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none" + className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none" /> {busy ? ( @@ -141,7 +141,7 @@ export function Composer({ onClick={onStop} aria-label={t("chat.stop")} title={t("chat.stop")} - className="grid size-10 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]" + className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]" > @@ -152,10 +152,7 @@ export function Composer({ disabled={!canSend} aria-label={t("chat.send")} title={t("chat.send")} - className={cn( - "grid size-10 shrink-0 place-items-center rounded-2xl transition", - canSend ? "grad-ice text-[#06111f]" : "border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-subtle)] opacity-60", - )} + className="touch-target grad-ice grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] text-[#06111f] transition disabled:opacity-45" > @@ -199,14 +196,13 @@ export function Composer({ } /** - * Chip style for the two popover triggers (context, model). The open state - * reads pressed, because the same chip opens and closes its popover. + * Chip style for the two popover triggers (context, model): the shared chip + * language, read as pressed while the chip's own popover is open (the same + * chip opens and closes it). */ function pickerChipClass(open: boolean): string { return cn( - "flex min-w-0 items-center gap-1.5 rounded-xl border px-3 py-2 text-[12.5px] font-medium transition", - open - ? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]" - : "border-[var(--hairline)] bg-[var(--surface-sunken)] text-[var(--text)] hover:bg-[var(--hover)]", + "chip min-w-0 px-3 py-2 text-[12.5px] font-medium", + open && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]", ); } diff --git a/components/chat/ContextPicker.tsx b/components/chat/ContextPicker.tsx index 9ead5be..440113c 100644 --- a/components/chat/ContextPicker.tsx +++ b/components/chat/ContextPicker.tsx @@ -105,10 +105,10 @@ export function ContextPicker({ ) } className={cn( - "flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition", + "flex w-full items-center gap-3 rounded-[var(--radius-control)] border px-3 py-3 text-left transition", scope.kind === value ? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]" - : "border-[var(--hairline-soft)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]", + : "border-[var(--glass-border)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]", )} > {icon} @@ -125,7 +125,7 @@ export function ContextPicker({ {modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), )} {modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), )} {scope.kind === "decks" ? ( -
+
{folders.map((folder) => { const inFolder = decks.filter((deck) => deck.folderId === folder.id); if (inFolder.length === 0) return null; diff --git a/components/chat/CopyIconButton.tsx b/components/chat/CopyIconButton.tsx index 0cf85d5..34d30dd 100644 --- a/components/chat/CopyIconButton.tsx +++ b/components/chat/CopyIconButton.tsx @@ -23,7 +23,7 @@ export function CopyIconButton({ text, className }: { text: string; className?: window.setTimeout(() => setState("idle"), 1800); }} className={cn( - "grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]", + "grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]", className, )} > diff --git a/components/chat/HistoryPanel.tsx b/components/chat/HistoryPanel.tsx index b134806..5a00b77 100644 --- a/components/chat/HistoryPanel.tsx +++ b/components/chat/HistoryPanel.tsx @@ -61,7 +61,7 @@ export function ChatHistoryList({ onChange={(event) => onQueryChange(event.target.value)} placeholder={t("chat.searchChats")} aria-label={t("chat.searchChats")} - className="min-w-0 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13px]" + className="min-w-0 flex-1 rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13px]" /> @@ -108,7 +108,7 @@ export function ChatHistoryList({ type="button" onClick={() => setRenaming(null)} aria-label={t("common.cancel")} - className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)]" + className="grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)]" > @@ -120,7 +120,7 @@ export function ChatHistoryList({ onClick={() => onOpen(conversation.id)} aria-current={active ? "true" : undefined} className={cn( - "min-w-0 flex-1 rounded-xl px-2.5 py-2 text-left transition", + "min-w-0 flex-1 rounded-[var(--radius-chip)] px-2.5 py-2 text-left transition", active ? "bg-[var(--nav-active-bg)]" : "hover:bg-[var(--hover)]", )} > @@ -136,7 +136,7 @@ export function ChatHistoryList({ onClick={() => startRename(conversation)} aria-label={t("common.rename")} title={t("common.rename")} - className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]" + className="grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]" > @@ -145,7 +145,7 @@ export function ChatHistoryList({ onClick={() => onDelete(conversation)} aria-label={t("chat.deleteChat")} title={t("chat.deleteChat")} - className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-500" + className="grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-500" > diff --git a/components/chat/MessageRow.tsx b/components/chat/MessageRow.tsx index a53b3b3..14b526e 100644 --- a/components/chat/MessageRow.tsx +++ b/components/chat/MessageRow.tsx @@ -44,7 +44,7 @@ export const MessageRow = memo(function MessageRow({ if (message.role === "user") { return (
-
+

{message.content}

{message.attachments && message.attachments.length > 0 ? (

@@ -56,7 +56,8 @@ export const MessageRow = memo(function MessageRow({