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.
This commit is contained in:
Martin Dvořák 2026-10-14 21:32:50 +00:00
commit 786d6cf1ae
21 changed files with 562 additions and 290 deletions

View file

@ -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 /

View file

@ -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;

View file

@ -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.
<p className={cn(!plain && "hint-line", "text-center text-[11.5px] leading-snug text-[var(--text-subtle)]", className)}>
// 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.
<p className={cn(!plain && "hint-line", "ai-disclaimer", className)}>
{t("common.aiDisclaimer")}
</p>
);

View file

@ -26,31 +26,33 @@ export function IceBackdrop() {
<span className="ice-blob ice-blob-b" />
<span className="ice-blob ice-blob-c" />
<span className="ice-blob ice-blob-d" />
<span className="ice-orb ice-orb-1" />
<span className="ice-orb ice-orb-2" />
<span className="ice-orb ice-orb-3" />
<span className="ice-orb ice-orb-4" />
<span className="ice-orb ice-orb-5" />
{/* 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). */}
<span className="ice-bubble ice-bubble-1" />
<span className="ice-bubble ice-bubble-2" />
<span className="ice-bubble ice-bubble-3" />
<span className="ice-bubble ice-bubble-4" />
<span className="ice-bubble ice-bubble-5" />
<span className="ice-bubble ice-bubble-6" />
<span className="ice-bubble ice-bubble-7" />
<span className="ice-bubble ice-bubble-8" />
<span className="ice-bubble ice-bubble-9" />
<span className="ice-bubble ice-bubble-10" />
<span className="ice-bubble ice-bubble-11" />
<span className="ice-bubble ice-bubble-12" />
<span className="ice-bubble ice-bubble-13" />
<span className="ice-bubble ice-bubble-14" />
<span className="ice-bubble ice-bubble-15" />
<span className="ice-bubble ice-bubble-16" />
<span className="ice-bubble ice-bubble-17" />
<span className="ice-bubble ice-bubble-18" />
<div className="ice-floats">
<span className="ice-orb ice-orb-1" />
<span className="ice-orb ice-orb-2" />
<span className="ice-orb ice-orb-3" />
<span className="ice-orb ice-orb-4" />
<span className="ice-orb ice-orb-5" />
{/* 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). */}
<span className="ice-bubble ice-bubble-1" />
<span className="ice-bubble ice-bubble-2" />
<span className="ice-bubble ice-bubble-3" />
<span className="ice-bubble ice-bubble-4" />
<span className="ice-bubble ice-bubble-5" />
<span className="ice-bubble ice-bubble-6" />
<span className="ice-bubble ice-bubble-7" />
<span className="ice-bubble ice-bubble-8" />
<span className="ice-bubble ice-bubble-9" />
<span className="ice-bubble ice-bubble-10" />
<span className="ice-bubble ice-bubble-11" />
<span className="ice-bubble ice-bubble-12" />
<span className="ice-bubble ice-bubble-13" />
<span className="ice-bubble ice-bubble-14" />
<span className="ice-bubble ice-bubble-15" />
<span className="ice-bubble ice-bubble-16" />
<span className="ice-bubble ice-bubble-17" />
<span className="ice-bubble ice-bubble-18" />
</div>
<span className="ice-grain" />
</div>
);

View file

@ -285,31 +285,30 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
<GearIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.settings")}</span> : null}
</button>
{/* 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 : (
<button
type="button"
onClick={() => {
haptic("toggle");
toggle();
}}
aria-label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
{theme === "dark" ? (
<SunIcon className="size-4 shrink-0 opacity-70" />
) : (
<MoonIcon className="size-4 shrink-0 opacity-70" />
)}
{!collapsed ? (
<span className="flex-1 truncate text-left">
{theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
</span>
) : null}
</button>
)}
{/* 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. */}
<button
type="button"
onClick={() => {
haptic("toggle");
toggle();
}}
aria-label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
{theme === "dark" ? (
<SunIcon className="size-4 shrink-0 opacity-70" />
) : (
<MoonIcon className="size-4 shrink-0 opacity-70" />
)}
{!collapsed ? (
<span className="flex-1 truncate text-left">
{theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
</span>
) : null}
</button>
</div>
</div>
);

View file

@ -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 (
<button
type="button"

View file

@ -578,22 +578,22 @@ export function ChatScreen() {
}
return (
<div className="flex h-full min-h-0 w-full">
<div className="flex h-full min-h-0 w-full" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
{/* Desktop's conversation list lives in the main sidebar (on /chat). */}
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
<header className="glass chat-header sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
<div className="chat-column relative flex h-full min-h-0 min-w-0 flex-1 flex-col gap-[var(--layout-gutter)] p-[var(--layout-gutter)]">
<header className="glass-panel chat-header z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
<button
type="button"
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
aria-label={t("chat.back")}
title={t("chat.back")}
className="grid size-11 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
className="grid size-11 shrink-0 place-items-center rounded-[var(--radius-control)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
>
<ChevronLeftIcon className="size-5" />
</button>
<div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{title || t("chat.title")}</h1>
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
<p className="truncate text-[11.5px] text-[var(--text-muted)]">
{scopeText}
{seed ? ` · ${seed.label}` : ""}
</p>
@ -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"
>
<HistoryIcon className="size-5" />
</button>
<ThemeToggle
plain
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)]"
className="grid size-11 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)]"
/>
<button
type="button"
onClick={newChat}
aria-label={t("chat.new")}
title={t("chat.new")}
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)]"
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)]"
>
<ChatPlusIcon className="size-5" />
</button>
@ -638,16 +638,16 @@ export function ChatScreen() {
{messages.length === 0 ? (
<div className="chat-empty space-y-5 py-2">
<div className="text-center">
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
<ChatIcon className="size-6" />
</span>
<h2 className="text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-muted)]">
{t("chat.emptyHint")}
</p>
</div>
{missingNotes ? (
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
<p className="rounded-[var(--radius-control)] border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
{t("chat.oldDecksNote")}
</p>
) : 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}
</button>
@ -697,10 +697,9 @@ export function ChatScreen() {
))}
</>
)}
<div className="h-2" aria-hidden />
{messages.length > 0 ? <div className="h-2" aria-hidden /> : null}
</div>
</div>
{!pinned && messages.length > 0 ? (
<button
type="button"
@ -721,7 +720,7 @@ export function ChatScreen() {
{error ? (
<div
role="alert"
className="mx-3 mb-2 shrink-0 rounded-2xl border border-rose-300/60 bg-rose-50/80 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
className="shrink-0 rounded-[var(--radius-control)] border border-rose-300/60 bg-rose-50/80 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
>
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{error.message}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">

View file

@ -70,13 +70,13 @@ export function Composer({
const canSend = online && !busy && value.trim().length > 0;
return (
<div className="chat-composer-band border-t border-[var(--glass-border)] bg-[var(--glass-bg)] px-4 pb-safe pt-2 backdrop-blur-xl lg:px-10">
<div className="chat-composer-band glass-panel px-4 pb-safe pt-2 lg:px-10">
{attachments.length > 0 ? (
<ul className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((entry, index) => (
<li
key={`${entry.attachment.name}-${index}`}
className="flex items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
className="flex items-center gap-1.5 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
>
<span className="max-w-[160px] truncate">{entry.attachment.name}</span>
<span className="text-[var(--text-subtle)]">{entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}</span>
@ -85,7 +85,7 @@ export function Composer({
aria-label={t("chat.attachRemove")}
title={t("chat.attachRemove")}
onClick={() => onRemoveAttachment(index)}
className="grid size-6 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
className="grid size-6 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-3.5" />
</button>
@ -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 ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
</button>
@ -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)]"
>
<StopIcon className="size-5" />
</button>
@ -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"
>
<SendIcon className="size-5" />
</button>
@ -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)]",
);
}

View file

@ -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)]",
)}
>
<span className="shrink-0 text-[var(--text-muted)]">{icon}</span>
@ -125,7 +125,7 @@ export function ContextPicker({
{modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), <LayersIcon className="size-5" />)}
{modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), <FolderIcon className="size-5" />)}
{scope.kind === "decks" ? (
<div className="max-h-[38dvh] space-y-1 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2">
<div className="max-h-[38dvh] space-y-1 overflow-y-auto overscroll-contain rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] p-2">
{folders.map((folder) => {
const inFolder = decks.filter((deck) => deck.folderId === folder.id);
if (inFolder.length === 0) return null;

View file

@ -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,
)}
>

View file

@ -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]"
/>
<Button size="sm" variant="secondary" className="touch-target shrink-0" onClick={onNew} aria-label={t("chat.new")}>
<PlusIcon className="size-4" />
@ -95,12 +95,12 @@ export function ChatHistoryList({
if (event.key === "Escape") setRenaming(null);
}}
aria-label={t("common.rename")}
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2 py-1.5 text-[16px] text-[var(--text)] outline-none sm:text-[13px]"
className="min-w-0 flex-1 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2 py-1.5 text-[16px] text-[var(--text)] outline-none sm:text-[13px]"
/>
<button
type="submit"
aria-label={t("common.save")}
className="grid size-9 shrink-0 place-items-center rounded-lg text-emerald-600 transition hover:bg-[var(--hover)] dark:text-emerald-300"
className="grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] text-emerald-600 transition hover:bg-[var(--hover)] dark:text-emerald-300"
>
<CheckCircleIcon className="size-4" />
</button>
@ -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)]"
>
<XIcon className="size-4" />
</button>
@ -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)]"
>
<PencilIcon className="size-3.5" />
</button>
@ -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"
>
<XIcon className="size-3.5" />
</button>

View file

@ -44,7 +44,7 @@ export const MessageRow = memo(function MessageRow({
if (message.role === "user") {
return (
<div className="flex flex-col items-end gap-1">
<div className="chat-bubble max-w-[88%] rounded-3xl rounded-br-lg border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5">
<div className="chat-bubble max-w-[88%] rounded-[var(--radius-panel)] rounded-br-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5">
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{message.content}</p>
{message.attachments && message.attachments.length > 0 ? (
<p className="mt-1.5 text-[11.5px] text-[var(--text-subtle)]">
@ -56,7 +56,8 @@ export const MessageRow = memo(function MessageRow({
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 rounded-lg px-1.5 py-1 text-[11.5px] text-[var(--text-subtle)] transition hover:text-[var(--text)]"
title={t("chat.edit")}
className="flex items-center gap-1 rounded-[var(--radius-chip)] px-1.5 py-1 text-[11.5px] text-[var(--text-subtle)] transition hover:text-[var(--text)]"
>
<PencilIcon className="size-3.5" />
{t("chat.edit")}
@ -88,7 +89,7 @@ export const MessageRow = memo(function MessageRow({
{message.error ? (
<div
role="alert"
className="rounded-2xl border border-rose-300/60 bg-rose-50/70 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
className="rounded-[var(--radius-control)] border border-rose-300/60 bg-rose-50/70 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
>
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{message.error.message}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
@ -109,7 +110,7 @@ export const MessageRow = memo(function MessageRow({
<button
type="button"
onClick={onRegenerate}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
className="flex items-center gap-1.5 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<RefreshIcon className="size-3.5" />
{t("chat.regenerate")}
@ -119,7 +120,7 @@ export const MessageRow = memo(function MessageRow({
<button
type="button"
onClick={() => onToFlashcards(message)}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
className="flex items-center gap-1.5 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<SparklesIcon className="size-3.5" />
{t("chat.toFlashcards")}
@ -129,7 +130,7 @@ export const MessageRow = memo(function MessageRow({
<button
type="button"
onClick={() => onSaveNotes(message)}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
className="flex items-center gap-1.5 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<PlusIcon className="size-3.5" />
{t("chat.saveToNotes")}
@ -146,7 +147,7 @@ export const MessageRow = memo(function MessageRow({
<Link
key={`${source.deckId}-${source.section}-${index}`}
href={`/deck/${source.deckId}`}
className="max-w-[220px] truncate rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
className="max-w-[220px] truncate rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
title={`${source.deckTitle} — ${source.section}`}
>
{source.deckTitle} · {source.section}

View file

@ -86,7 +86,7 @@ export function ModelPicker({
aria-label={t("chat.modelSearch")}
autoComplete="off"
spellCheck={false}
className="mb-2 w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
className="mb-2 w-full rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
/>
) : null}
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain">
@ -107,17 +107,17 @@ export function ModelPicker({
onClose();
}}
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",
selected
? "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)]",
unavailable && "opacity-55",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">{model.name}</span>
<span className="mt-0.5 flex items-center gap-2 text-[11.5px] text-[var(--text-subtle)]">
<span className="rounded-md border border-[var(--hairline-soft)] px-1.5 py-0.5">
<span className="rounded-[var(--radius-chip)] border border-[var(--glass-border)] px-1.5 py-0.5">
{model.provider ? `${providerLabel(model.provider)} · ${badge(model)}` : badge(model)}
</span>
{model.available === true ? (

View file

@ -1,6 +1,6 @@
{
"name": "freezmo",
"version": "0.5.9",
"version": "0.6.0",
"private": true,
"license": "GPL-3.0-only",
"scripts": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Before After
Before After

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v81";
const CACHE_VERSION = "freezmo-v82";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -75,6 +75,26 @@ async function orbAt(page, phase) {
check("orbs: own duration per pass (30-70s, 5 distinct)", orbs.every((orb) => orb.duration >= 30 && orb.duration <= 70) && new Set(orbs.map((orb) => orb.duration)).size === 5, orbs.map((orb) => orb.duration).join(","));
check("orbs: reduced opacity token applied", orbs.every((orb) => orb.opacity > 0.3 && orb.opacity < 0.45), String(orbs[0]?.opacity));
/* The floats layer: orbs + bubbles live in one masked wrapper that fades
them out before the chrome bands, so none of them is sliced by a panel
edge (see .ice-floats in globals.css). */
const floats = await page.evaluate(() => {
const layer = document.querySelector(".ice-floats");
const style = layer ? getComputedStyle(layer) : null;
return {
children: layer ? layer.children.length : 0,
orbs: document.querySelectorAll(".ice-floats .ice-orb").length,
bubbles: document.querySelectorAll(".ice-floats .ice-bubble").length,
mask: style ? style.maskImage || style.webkitMaskImage : "",
};
});
check(
"floats: orbs and bubbles ride one masked layer",
floats.children === 23 && floats.orbs === 5 && floats.bubbles === 18,
JSON.stringify(floats),
);
check("floats: the chrome bands fade the layer out", floats.mask.includes("gradient"), floats.mask.slice(0, 60));
const keyframes = await page.evaluate(() => {
for (const sheet of document.styleSheets) {
try {
@ -153,8 +173,17 @@ async function orbAt(page, phase) {
const orb = document.querySelector(".ice-orb-1");
const style = getComputedStyle(orb);
const rect = orb.getBoundingClientRect();
return { animation: style.animationName, willChange: style.willChange, opacity: Number(style.opacity), top: rect.top, left: rect.left };
const floats = getComputedStyle(document.querySelector(".ice-floats"));
return {
animation: style.animationName,
willChange: style.willChange,
opacity: Number(style.opacity),
top: rect.top,
left: rect.left,
mask: floats.maskImage || floats.webkitMaskImage,
};
});
check("reduced motion: the floats mask is off (parked orbs keep their spots)", state.mask === "none", state.mask);
check("reduced motion: rise removed, layer released", state.animation === "none" && state.willChange === "auto", JSON.stringify(state));
check("reduced motion: orb visible and faded in at a static spot", state.opacity > 0.08 && state.top > 0 && state.top < 900, JSON.stringify(state));
await page.waitForTimeout(1100);

View file

@ -291,49 +291,112 @@ try {
await desktop.page.waitForTimeout(400);
check("desktop: AI disclaimer under the composer", await desktop.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
/* The chat chrome ends at the rail with the rail card's own radius: 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 now keep the rail's 12px gutter
and end in a 1.5rem rounded corner on their left side, with their
hairlines inset instead of touching the seam. */
const seam = await desktop.page.evaluate(() => {
/* The chat chrome floats: the header, the empty-state card and the
composer keep the rail's own 12px gutter on every side (the shared
--layout-gutter) and the panel radius on all four corners. Before, the
header and composer were full-bleed bands — flush to the viewport edge,
the composer's bottom corners cropped off-screen. The empty-state card
is Aero's frosted panel, so this block runs there. */
const aero = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
await seed(aero.page, { decks: [DECK_NOTES], theme: "aero" });
await aero.page.goto(`${APP}/chat`, { waitUntil: "networkidle" });
await aero.page.waitForTimeout(1200);
const chrome = await aero.page.evaluate(() => {
const card = document.querySelector(".sidebar-surface");
const header = document.querySelector(".chat-header");
const band = document.querySelector(".chat-composer-band");
if (!card || !header || !band) return null;
const line = (el) => {
const cs = getComputedStyle(el, "::after");
return { content: cs.content, left: parseFloat(cs.left) };
const empty = document.querySelector(".chat-empty");
if (!card || !header || !band || !empty) return null;
const box = (el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
left: Math.round(rect.left),
top: Math.round(rect.top),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
tl: style.borderTopLeftRadius,
tr: style.borderTopRightRadius,
bl: style.borderBottomLeftRadius,
br: style.borderBottomRightRadius,
border: style.borderTopWidth,
wash: style.backgroundImage.includes("background-glass"),
};
};
const hcs = getComputedStyle(header);
return {
cardRight: Math.round(card.getBoundingClientRect().right),
headerLeft: Math.round(header.getBoundingClientRect().left),
bandLeft: Math.round(band.getBoundingClientRect().left),
headerRadius: hcs.borderStartStartRadius,
headerEndRadius: hcs.borderEndStartRadius,
headerBorder: hcs.borderBottomWidth,
bandBorder: getComputedStyle(band).borderTopWidth,
headerLine: line(header),
bandLine: line(band.parentElement),
card: box(card),
header: box(header),
band: box(band),
empty: box(empty),
viewport: { width: window.innerWidth, height: window.innerHeight },
};
});
check(
"desktop: chat chrome ends in a rounded corner at the seam",
seam !== null &&
seam.headerLeft === seam.cardRight + 12 &&
seam.bandLeft === seam.cardRight + 12 &&
seam.headerRadius === "24px" &&
seam.headerEndRadius === "24px" &&
seam.headerBorder === "0px" &&
seam.bandBorder === "0px" &&
seam.headerLine.content !== "none" &&
seam.headerLine.left > 0 &&
seam.bandLine.content !== "none" &&
seam.bandLine.left > 0,
JSON.stringify(seam),
"desktop: chat panels float on the rail's gutter",
chrome !== null &&
chrome.card.left === 12 &&
chrome.header.left === chrome.card.right + 12 &&
chrome.empty.left === chrome.header.left &&
chrome.band.left === chrome.header.left &&
chrome.header.right === chrome.empty.right &&
chrome.header.right === chrome.band.right &&
chrome.viewport.width - chrome.header.right === 12 &&
chrome.header.top === 12 &&
chrome.viewport.height - chrome.band.bottom === 12,
JSON.stringify(chrome),
);
check(
"desktop: every chat panel is rounded on all four corners",
chrome !== null &&
[chrome.header, chrome.empty, chrome.band].every(
(panel) =>
panel.tl === "24px" && panel.tr === "24px" && panel.bl === "24px" && panel.br === "24px" && panel.border === "1px",
),
JSON.stringify(chrome),
);
check(
"desktop: the rail and the chat chrome share one even wash",
chrome !== null && [chrome.card, chrome.header, chrome.empty, chrome.band].every((panel) => panel.wash),
chrome ? [chrome.card.wash, chrome.header.wash, chrome.empty.wash, chrome.band.wash].join(",") : "missing",
);
check(
"desktop: the empty state is centered between header and composer",
chrome !== null && Math.abs(chrome.empty.top - chrome.header.bottom - (chrome.band.top - chrome.empty.bottom)) <= 1,
chrome ? `${chrome.empty.top - chrome.header.bottom} / ${chrome.band.top - chrome.empty.bottom}` : "missing",
);
await aero.context.close();
/* The composer's controls share one language: send is a filled primary
tile shaped like the attach tile (it used to be a pale outline that read
as disabled), and the chips/inputs carry the shared radii. */
const controls = await desktop.page.evaluate(() => {
const style = (el) => {
const cs = getComputedStyle(el);
return {
image: cs.backgroundImage,
radius: cs.borderTopLeftRadius,
height: Math.round(el.getBoundingClientRect().height),
};
};
return {
send: style(document.querySelector('button[title="Send"]')),
attach: style(document.querySelector('button[title="Attach photos or documents"]')),
chip: style(document.querySelector(".chat-composer-band .chip")),
input: style(document.querySelector(".chat-composer-band textarea")),
};
});
check(
"desktop: send is a filled tile shaped like attach",
controls.send.image.includes("gradient") &&
controls.send.radius === controls.attach.radius &&
controls.send.height === controls.attach.height &&
controls.attach.height >= 40,
JSON.stringify(controls),
);
check(
"desktop: chips and the input share the control radii",
controls.chip.height >= 40 && controls.chip.radius === "12px" && controls.input.radius === "14px",
JSON.stringify(controls),
);
/* The notes-off banner is gone: with "keep notes text" off, chat no longer

View file

@ -251,9 +251,10 @@ const AXE = axeSource();
);
check("aero: the status bar follows the water", state.themeColor === "#2fa7e6", String(state.themeColor));
check(
"aero: the light/dark toggle is gone",
(await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 0,
"aero: the theme toggle stays (it offers dark)",
(await page.getByRole("button", { name: "Switch to dark mode" }).count()) >= 1,
);
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
@ -263,26 +264,76 @@ const AXE = axeSource();
(await picker.count()) === 1 &&
(await picker.getByRole("button", { name: "Frutiger Aero" }).getAttribute("aria-pressed")) === "true",
);
/* Phones float the chat chrome with the same gutter and radii as the
desktop rail — the header and composer used to be flush to the viewport,
with the composer's bottom corners cropped off-screen. */
await page.goto(`${BASE}/chat`, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
const phoneChrome = await page.evaluate(() => {
const header = document.querySelector(".chat-header");
const band = document.querySelector(".chat-composer-band");
const empty = document.querySelector(".chat-empty");
if (!header || !band || !empty) return null;
const box = (el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
left: Math.round(rect.left),
top: Math.round(rect.top),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
radius: style.borderTopLeftRadius,
bottomRadius: style.borderBottomRightRadius,
};
};
return { header: box(header), band: box(band), empty: box(empty), viewport: { width: window.innerWidth, height: window.innerHeight } };
});
check(
"aero phone: chat panels float with one gutter and rounded corners",
phoneChrome !== null &&
phoneChrome.header.top === 12 &&
phoneChrome.header.left === 12 &&
phoneChrome.viewport.width - phoneChrome.header.right === 12 &&
phoneChrome.viewport.height - phoneChrome.band.bottom === 12 &&
phoneChrome.band.left === phoneChrome.header.left &&
phoneChrome.empty.left === phoneChrome.header.left &&
[phoneChrome.header, phoneChrome.band, phoneChrome.empty].every((panel) => panel.radius === "24px" && panel.bottomRadius === "24px"),
JSON.stringify(phoneChrome),
);
await context.close();
}
/* 6b. picking Aero hides the light/dark switches immediately ---------- */
/* 6b. picking Aero keeps a light/dark way out, without a reload --------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 900 });
await seed(page, { decks: [DECK], theme: "dark" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
const toggleInRail = () =>
page.evaluate(() => [...document.querySelectorAll("aside button")].some((b) => /Light mode|Dark mode/.test(b.textContent ?? "")));
check("theme: the rail has a light/dark row while dark", await toggleInRail());
const railRow = () =>
page.evaluate(() => {
const button = [...document.querySelectorAll("aside button")].find((b) => /Light mode|Dark mode/.test(b.textContent ?? ""));
return button ? (button.textContent ?? "").trim() : null;
});
check("theme: the rail has a light/dark row while dark", (await railRow())?.includes("Light mode") === true, String(await railRow()));
await page.locator("aside button").filter({ hasText: /Settings|Nastavení/ }).first().click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(500);
await page.getByRole("group", { name: "Theme" }).getByRole("button", { name: "Frutiger Aero" }).click();
await page.waitForTimeout(600);
check(
"theme: picking Aero hides it without a reload",
(await page.evaluate(() => document.documentElement.classList.contains("aero"))) && !(await toggleInRail()),
"theme: picking Aero flips the row to Dark mode without a reload",
(await page.evaluate(() => document.documentElement.classList.contains("aero"))) && (await railRow())?.includes("Dark mode") === true,
String(await railRow()),
);
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
await page.locator("aside button").filter({ hasText: /Dark mode/ }).first().click();
await page.waitForTimeout(500);
check(
"theme: the Aero row is a real way out (switches to dark)",
await page.evaluate(() => document.documentElement.classList.contains("dark") && !document.documentElement.classList.contains("aero")),
);
await context.close();
}