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

@ -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();
}