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