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

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