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:
parent
8153fc8dae
commit
786d6cf1ae
21 changed files with 562 additions and 290 deletions
33
README.md
33
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 /
|
||||
|
|
|
|||
364
app/globals.css
364
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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function IceBackdrop() {
|
|||
<span className="ice-blob ice-blob-b" />
|
||||
<span className="ice-blob ice-blob-c" />
|
||||
<span className="ice-blob ice-blob-d" />
|
||||
<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" />
|
||||
|
|
@ -51,6 +52,7 @@ export function IceBackdrop() {
|
|||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -285,9 +285,9 @@ 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 : (
|
||||
{/* 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={() => {
|
||||
|
|
@ -309,7 +309,6 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
|
|||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)]",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 |
BIN
public/aero/background-glass.webp
Normal file
BIN
public/aero/background-glass.webp
Normal file
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 |
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 hcs = getComputedStyle(header);
|
||||
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 {
|
||||
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),
|
||||
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"),
|
||||
};
|
||||
};
|
||||
return {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue