app: the app is one surface, not a tray of cards

Ran v4 side by side to see what the difference actually was, and it was
not spacing. v4 fills the window edge to edge and divides its zones with
hairlines; v5 floated rounded, shadowed cards on a visible page
background. One reads as an editing suite, the other as a dashboard.

So panel gains a second variant. "default" is still a card, for something
that floats -- inside a window, on a page. "zone" is a region of the app:
square, no shadow, no border of its own, divided from its neighbours by
the one hairline everything else uses. The workspace keeps no gutter
between regions; the preview zone is flush, because padding around a
picture is a frame nobody asked for, and its surround is letterbox black
rather than a panel colour.

The right column was two stacked panels with two heads for one thing.
It is one zone now.

And the surfaces have depth, derived from each theme's own elevation
scale so neither needs a rule of its own: a vertical fall on every band
and every raised control, the accent gradient and a bloom under a primary
button, a wash off the top-right of the page, tokenised thin scrollbars,
an accent mark on every zone title, and v4's film grain -- overlay at
3.5% on dark, multiply at 1.5% on light, and painted with no z-index so
anything that opens above the frame stays crisp.
This commit is contained in:
digital-garbage
2026-08-30 21:17:39 +03:00
parent 549e430806
commit 16eec81a03
9 changed files with 216 additions and 50 deletions
+5
View File
@@ -144,6 +144,11 @@ export const DEMOS = {
actions: [c.iconButton.sm({ icon: "✎", label: "Rename" }), c.iconButton.sm({ icon: "✕", label: "Remove" })],
body: c.text.sm({ text: "A slow push in on the rooftop as the light goes." }),
}),
"panel.zone": (c) => ({
title: "Assets",
actions: [c.iconButton.sm({ icon: "▦", label: "Grid" })],
body: c.text.sm({ text: "A region of the app: square, edge to edge, divided by a hairline." }),
}),
"region.stack": (c) => ({
label: "Pipeline",
children: [
+6 -3
View File
@@ -1,7 +1,7 @@
.cx-btn {
display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
border: var(--bw) solid var(--line); border-radius: var(--ctl-r);
background: var(--elev-2); color: var(--text);
background: var(--grad-raise); color: var(--text);
font-family: var(--font-ui); font-weight: var(--fw-medium);
white-space: nowrap; cursor: pointer;
transition: background var(--dur-1) var(--ease-standard),
@@ -18,10 +18,13 @@
.cx-btn:disabled { opacity: var(--disabled-opacity); cursor: default; }
.cx-btn-primary {
background: var(--accent); border-color: var(--accent);
background: var(--grad-accent); border-color: var(--accent);
color: var(--on-accent); font-weight: var(--fw-bold);
box-shadow: var(--glow-accent);
}
.cx-btn-primary:hover:not(:disabled) { background: var(--accent-hi); border-color: var(--accent-hi); }
/* Brightened rather than repainted: a second accent colour for hover is a
second thing to keep in step with the first, and this one cannot drift. */
.cx-btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
.cx-btn-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
.cx-btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: var(--danger); }
+3 -2
View File
@@ -94,12 +94,13 @@
/* The result, shown whole. `contain` rather than `cover`: a generated frame
cropped to fit its box is a different picture from the one that was made. */
.cx-viewer { display: flex; min-block-size: 0; block-size: 100%; }
/* Letterbox black, not a panel colour: what is around a frame is part of how
the frame reads, and a dark grey surround makes a dark shot look washed. */
.cx-viewer-stage {
flex: 1 1 auto;
display: flex; align-items: center; justify-content: center;
min-block-size: 0; min-inline-size: 0;
background: var(--elev-0);
border-radius: var(--r-4);
background: var(--void);
}
.cx-viewer-media {
max-inline-size: 100%; max-block-size: 100%;
+46 -14
View File
@@ -49,6 +49,7 @@
.cx-panel {
border: var(--bw) solid var(--line); border-radius: var(--r-4);
background: var(--elev-1); overflow: hidden;
box-shadow: var(--shadow-1);
/* A column, so a panel given a height passes it to its body. Without this a
viewer asking for 100% of its parent got 100% of `auto` and collapsed to
one line, inside a panel that was the right size around it. */
@@ -59,13 +60,27 @@
title was 34, so two panels side by side had their titles on different lines
-- the whole row read as if it had been assembled by hand. The height is the
band's; a control inside is centred in it. */
/* A zone is the app's own surface, not a card on it: no corners to round, no
shadow to cast, and no border of its own -- the hairline between it and the
next region belongs to the region, so two neighbours never draw two lines. */
.cx-zone { border: 0; border-radius: 0; box-shadow: none; }
.cx-panel-head {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-5);
padding: 0 var(--sp-6); border-bottom: var(--bw) solid var(--line);
background: var(--elev-2);
background: var(--grad-band);
block-size: var(--menubar-h); flex: none;
}
.cx-panel-title { font-family: var(--font-display); font-size: var(--fs-3); font-weight: var(--fw-semibold); }
.cx-panel-title {
font-family: var(--font-display); font-size: var(--fs-3); font-weight: var(--fw-semibold);
display: flex; align-items: center; gap: var(--sp-3);
}
/* One accent mark per panel. Carried over from v4, where it is what makes a
head read as the top of something rather than as a strip of grey. */
.cx-panel-title::before {
content: ""; flex: none; inline-size: 5px; block-size: 5px;
border-radius: var(--r-pill); background: var(--accent);
}
.cx-panel-actions { display: flex; gap: var(--sp-2); }
/* A column with a gap, not a block: two things put in a body by two different
parts of the app had whatever space their own line boxes happened to leave,
@@ -78,13 +93,14 @@
/* One child fills the body -- a viewer, a bin. Several are each their own
height and the body scrolls, which is what a panel of controls does. */
.cx-panel-body > .cx-viewer, .cx-panel-body > .cx-stack-fill { flex: 1 1 auto; }
.cx-panel-flush > .cx-panel-body { padding: 0; }
.cx-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
.cx-action-bar {
display: flex; align-items: center; gap: var(--sp-6);
padding: var(--sp-5) var(--gutter);
border-top: var(--bw) solid var(--line); background: var(--elev-2);
border-top: var(--bw) solid var(--line); background: var(--grad-band);
position: sticky; bottom: 0;
}
/* What the app is SAYING sits at the start of the row and what is PRESSED at
@@ -176,7 +192,12 @@
.cx-collapsible-head {
display: flex; align-items: center; gap: var(--sp-3);
padding: 0 var(--sp-6); block-size: var(--menubar-h); cursor: pointer; list-style: none;
font-size: var(--fs-5);
font-size: var(--fs-5); background: var(--grad-band);
border-radius: calc(var(--r-4) - var(--bw));
}
.cx-collapsible[open] > .cx-collapsible-head {
border-end-start-radius: 0; border-end-end-radius: 0;
border-block-end: var(--bw) solid var(--line);
}
.cx-collapsible-head::-webkit-details-marker { display: none; }
.cx-collapsible-head::before {
@@ -202,10 +223,6 @@
never inside the panel it controls. */
.cx-workspace {
display: flex; align-items: stretch; min-block-size: 0; block-size: 100%; inline-size: 100%;
/* The gutter, in the one place that can apply it evenly. Regions carrying
their own padding gave a 7px gap on one side of the centre and 19 on the
other, and the three columns started at three different heights. */
gap: var(--gutter); padding: var(--gutter);
}
/* Flex containers, not blocks with overflow. A region whose height comes from
its content gives a vertical split inside it a basis of `auto`, so the split's
@@ -214,6 +231,7 @@
.cx-workspace-main {
flex: 1 1 auto; min-inline-size: 0; min-block-size: 0;
display: flex; flex-direction: column;
background: var(--elev-1);
}
.cx-workspace-main > *, .cx-workspace-side > * { flex: 1 1 auto; min-block-size: 0; }
.cx-workspace-side {
@@ -227,6 +245,11 @@
min-block-size: 0;
overflow: auto;
display: flex; flex-direction: column;
background: var(--elev-1);
/* The divider between one region and the next. One hairline, the same one a
splitter draws, so the app reads as a single surface that has been cut up
rather than as three things put next to each other. */
border-inline: var(--bw) solid var(--line);
}
/* Width nil rather than display:none -- the panel stays in the flow, stays
measurable, and can come back without being rebuilt.
@@ -238,26 +261,35 @@
.cx-workspace-side.cx-collapsed {
inline-size: 0;
overflow: hidden;
border-inline-width: 0;
}
/* A closed panel is still a flex item, so the gutter is still applied on both
sides of nothing -- a double-width gap where a panel used to be, which is
exactly the kind of hole that reads as a bug. One of the two is taken back. */
.cx-workspace-left.cx-collapsed { margin-inline-end: calc(var(--gutter) * -1); }
.cx-workspace-right.cx-collapsed { margin-inline-start: calc(var(--gutter) * -1); }
/* The toggle sits in the head band's height, so it lines up with the panel
titles either side of it instead of floating a few pixels above them like a
mark left on the glass. */
/* The rails are the frame's own edge, so they carry the page colour rather than
a zone's -- the one strip of background the app shows. */
.cx-workspace-rail {
flex: 0 0 auto;
display: flex;
align-items: flex-start;
justify-content: center;
background: var(--elev-0);
padding-inline: var(--sp-1);
}
.cx-workspace-rail > * { block-size: var(--menubar-h); }
/* The page frame. The one place a definite height is established, so everything
below can fill instead of each pane guessing at a percentage of nothing. */
.cx-frame { display: flex; flex-direction: column; block-size: 100%; min-block-size: 0; }
/* Film grain over the app, and only over the app: no z-index, so anything that
opens above the frame -- a modal, a popover -- paints over it and stays
crisp. Fixed, so it does not scroll and cannot be captured by a scroller's
own stacking context. */
.cx-frame::after {
content: ""; position: fixed; inset: 0; pointer-events: none;
opacity: var(--grain-opacity); mix-blend-mode: var(--grain-blend);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.cx-frame-main { flex: 1 1 auto; min-block-size: 0; display: flex; }
.cx-frame-main > * { flex: 1 1 auto; min-inline-size: 0; }
.cx-frame-head, .cx-frame-foot { flex: 0 0 auto; }
@@ -267,7 +299,7 @@
.cx-frame-head {
display: flex; align-items: center;
block-size: var(--menubar-h); padding-inline: var(--gutter);
border-block-end: var(--bw) solid var(--line); background: var(--elev-2);
border-block-end: var(--bw) solid var(--line); background: var(--grad-band);
}
.cx-frame-head > * { flex: 1 1 auto; min-inline-size: 0; }
+24 -5
View File
@@ -67,17 +67,36 @@ define("group", "default", ({ label, rows = [], hint } = {}) => {
return { node, destroy: () => node.remove() };
});
define("panel", "default", ({ title, actions = [], body } = {}) => {
/**
* A titled box. Two of them, and the difference is only where they are used.
*
* `default` is a card: rounded, bordered, with a shadow, for a panel that
* floats on something -- inside a window, on a page.
*
* `zone` is a REGION OF THE APP: square, edge to edge, divided from its
* neighbours by the same hairline everything else uses. The app is one surface
* cut into areas, not a tray of cards with the desk showing between them --
* which is what an editing suite looks like, and what v4 was.
*/
const panel = (variant) => ({ title, actions = [], body, flush = false } = {}) => {
const head = (title || actions.length)
? el("header", { cls: "cx-panel-head", children: [
title ? el("h2", { cls: "cx-panel-title", text: title }) : null,
actions.length ? el("div", { cls: "cx-panel-actions", children: actions.map((a) => nodeOf(a, "panel.default")) }) : null,
actions.length ? el("div", { cls: "cx-panel-actions", children: actions.map((a) => nodeOf(a, `panel.${variant}`)) }) : null,
].filter(Boolean) })
: null;
const content = el("div", { cls: "cx-panel-body", children: body ? nodeOf(body, "panel.default") : null });
const node = el("section", { cls: "cx-panel", children: [head, content].filter(Boolean) });
const content = el("div", { cls: "cx-panel-body", children: body ? nodeOf(body, `panel.${variant}`) : null });
// `flush` is for a body that IS the content -- a picture, a video, a canvas.
// Padding around one of those is a frame nobody asked for, and the thing
// being looked at is the reason the zone exists.
const node = el("section", { cls: ["cx-panel", variant === "zone" ? "cx-zone" : null,
flush ? "cx-panel-flush" : null],
children: [head, content].filter(Boolean) });
return { node, body: content, destroy: () => node.remove() };
});
};
define("panel", "default", panel("default"));
define("panel", "zone", panel("zone"));
// A region whose contents are replaced.
//
+20 -1
View File
@@ -5,7 +5,10 @@ html, body { height: 100%; }
body {
margin: 0;
background: var(--elev-0);
/* A wash over the base, not the base alone: a flat fill across 1440px reads
as paper, and every surface above it then has nothing to sit against. */
background: var(--wash), var(--elev-0);
background-attachment: fixed;
color: var(--text);
font-family: var(--font-ui);
font-size: var(--fs-4);
@@ -13,6 +16,22 @@ body {
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--accent-soft); }
/* Scrollbars are chrome, and the platform's own are sized for a document.
Left alone, a 15px light-grey bar cut through every dark panel in the app. */
* { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--scroll-thumb); border-radius: var(--r-4);
/* Inset by its own border so the thumb reads as floating in the track
rather than as a second edge beside the panel's. */
border: 2px solid transparent; background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hi); background-clip: padding-box; }
*::-webkit-scrollbar-corner { background: transparent; }
/* Headings and paragraphs carry no margin of their own — spacing is the
* container's job, so an element can be dropped anywhere without surprises. */
h1, h2, h3, h4, h5, h6, p, figure { margin: 0; font-size: inherit; font-weight: inherit; }
+38
View File
@@ -116,6 +116,11 @@
--void: #060606; /* media letterbox */
--shadow-ink: rgba(0, 0, 0, .85);
--backdrop: rgba(6, 5, 4, .66);
/* Film grain. `overlay` over a dark surface lifts it slightly and breaks up
the flatness of a large area of one colour, which is most of this app. */
--grain-opacity: .035;
--grain-blend: overlay;
}
/* ── light: white, grey and daylight blue ─────────────────────────────── */
@@ -153,6 +158,11 @@
--void: #0a0c0f;
--shadow-ink: rgba(24, 34, 48, .30);
--backdrop: rgba(24, 34, 48, .34);
/* The same grain over a light surface with `overlay` reads as dirt, so light
multiplies a much fainter one. v4 landed on the same two numbers. */
--grain-opacity: .015;
--grain-blend: multiply;
}
/* Derived values, written once so both themes get them for free. color-mix
@@ -173,6 +183,34 @@
--shadow-1: 0 1px 2px color-mix(in srgb, var(--shadow-ink) 45%, transparent);
--shadow-2: 0 4px 12px color-mix(in srgb, var(--shadow-ink) 60%, transparent);
--shadow-3: 0 12px 32px color-mix(in srgb, var(--shadow-ink) 80%, transparent);
/* ── surfaces ────────────────────────────────────────────────────────
Depth, derived from the palette rather than painted per component.
A flat fill everywhere is what made this read as a wireframe of itself:
every band, control and card was one colour with a 1px line around it,
so nothing sat above anything else.
Each of these is a vertical fall between two steps of the SAME theme's
elevation scale, so dark falls darker and light falls lighter, and
neither needs a rule of its own. */
--grad-band: linear-gradient(180deg, var(--elev-2),
color-mix(in srgb, var(--elev-2) 55%, var(--elev-0)));
--grad-raise: linear-gradient(180deg, color-mix(in srgb, var(--elev-2) 72%, var(--elev-3)),
var(--elev-2));
--grad-accent: linear-gradient(180deg, var(--accent), var(--accent-lo));
/* A filled accent control throws a little of its own colour. Negative
spread, so it is a bloom under the control and not a box around it. */
--glow-accent: 0 4px 14px -6px var(--accent);
/* The page is not one flat colour: a soft wash off the top-right corner,
which is what stops a 1440px-wide dark background looking like paper. */
--wash: radial-gradient(1200px 600px at 80% -10%,
color-mix(in srgb, var(--elev-2) 70%, transparent) 0%,
transparent 60%);
--scroll-thumb: color-mix(in srgb, var(--text) 16%, transparent);
--scroll-thumb-hi: color-mix(in srgb, var(--text) 28%, transparent);
}
/* One guard covers the whole app, because every duration is a token.
+15 -11
View File
@@ -28,12 +28,12 @@ export function build(root, handlers = {}) {
// bin owns the choice of what the viewer shows -- one place decides, so a
// click on an older result and a run finishing cannot both be right at once.
const bin = createBin({ onOpen: (item) => viewer.setSource(item.url, item.kind) });
const assets = composer.panel.default({
const assets = composer.panel.zone({
title: "Assets",
actions: [bin.control],
body: bin.host,
});
const preview = composer.panel.default({ title: "Preview", body: viewer });
const preview = composer.panel.zone({ title: "Preview", body: viewer, flush: true });
// The stand-ins below are EMPTY STATES, not labels: they say a region is
// empty, and settle() takes them down once it is not. A line reading "modules
// appear here" left above the modules that appeared is a region explaining
@@ -43,7 +43,7 @@ export function build(root, handlers = {}) {
title: "No prompt here",
hint: "The pipeline decides which of its inputs appear on the main window.",
});
const prompt = composer.panel.default({ title: "Prompt", body: promptEmpty });
const prompt = composer.panel.zone({ title: "Prompt", body: promptEmpty });
// The result gets the room. The prompt is a few lines and a button's worth of
// controls; at 38% of the height it was mostly empty space taken from the one
// thing on the page anybody is looking at. Draggable, so anyone writing a long
@@ -63,7 +63,8 @@ export function build(root, handlers = {}) {
title: "Nothing to set",
hint: "Modules with settings for a run appear here.",
});
const generation = composer.panel.default({ title: "Generation", body: generationEmpty });
const generation = composer.region.stack({ gap: "sm", label: "Generation",
children: [generationEmpty] });
const settingsEmpty = composer.emptyState.default({
icon: "⚙",
@@ -74,12 +75,15 @@ export function build(root, handlers = {}) {
// box is two frames drawn around one list of settings.
const settings = composer.region.stack({ gap: "sm", label: "Settings",
children: [settingsEmpty] });
// A stack, not a group: a group draws a bordered box with its own padding, so
// the two panels in this column sat 11px further in than the panel in the
// left column and inside a second border that meant nothing.
const properties = composer.region.stack({
gap: "sm", label: "Properties",
children: [generation, composer.collapsible.default({ label: "Settings", body: settings })],
// ONE zone, not two panels stacked. A region of the app is one area with one
// head on it; two heads in a column means two things, and there is only one
// thing here -- what the next run will do.
const properties = composer.panel.zone({
title: "Generation",
body: composer.region.stack({
gap: "sm", label: "Properties",
children: [generation, composer.collapsible.default({ label: "Settings", body: settings })],
}),
});
const workspace = composer.workspace.docked({
@@ -122,7 +126,7 @@ export function build(root, handlers = {}) {
// One host, five names. Each carries the same stand-in, and settle() takes it
// down once anything at all has mounted into the panel they share.
for (const point of ["model", "latent", "sampling", "timing", "post"]) {
offer(`generation.${point}`, generation.body, generationEmpty.node);
offer(`generation.${point}`, generation.node, generationEmpty.node);
}
return { workspace, assets, bin, preview, viewer, prompt, generation, settings,
+59 -14
View File
@@ -49,23 +49,32 @@ test("the three columns start at the same height", async ({ page }) => {
expect(new Set(tops), `columns start at ${tops.join(", ")}`).toHaveProperty("size", 1);
});
test("one gutter, everywhere", async ({ page }) => {
// Between the columns, and between two panels stacked in one of them. Three
// different numbers for three kinds of gap is what makes a layout read as
// accidental.
test("the regions meet on a hairline, not across a gap", async ({ page }) => {
// The app is one surface cut into areas, not a tray of cards with the desk
// showing between them. Before this the columns floated with the page
// background between them -- and with three different gaps at that.
await app(page);
const g = await gutter(page);
const cols = await page.evaluate(() => ["left", "main", "right"].map((which) => {
const b = document.querySelector(`.cx-workspace-${which}`).getBoundingClientRect();
return { x: Math.round(b.x), r: Math.round(b.right) };
}));
expect(cols[1].x - cols[0].r, "left column to centre").toBe(g);
expect(cols[2].x - cols[1].r, "centre to right column").toBe(g);
expect(cols[1].x - cols[0].r, "a gap between the left column and the centre").toBe(0);
expect(cols[2].x - cols[1].r, "a gap between the centre and the right column").toBe(0);
const divider = await page.evaluate(() =>
getComputedStyle(document.querySelector(".cx-workspace-left")).borderRightWidth);
expect(divider, "the regions are not divided at all").toBe("1px");
});
test("two zones stacked in a column are divided by a grabbable splitter", async ({ page }) => {
// A splitter is the one place a gap is right: it has to be wide enough to
// take hold of, and it draws its own hairline down the middle.
await app(page);
const g = await gutter(page);
const stacked = await boxes(page, ".cx-workspace-main .cx-panel");
expect(stacked.length, "the centre is not two stacked panels any more").toBe(2);
expect(stacked[1].y - stacked[0].b, "between two panels in one column").toBe(g);
expect(stacked.length, "the centre is not two stacked zones any more").toBe(2);
expect(stacked[1].y - stacked[0].b, "between two zones in one column").toBe(g);
});
test("every control in a list of settings shares its two edges", async ({ page }) => {
@@ -98,11 +107,8 @@ test("what the app says is at the start of the transport, what you press is at t
expect(buttons.x).toBeGreaterThan(lead.r);
});
test("a closed panel leaves one gutter behind it, not two", async ({ page }) => {
// A closed panel is still a flex item, so the gap is applied on both sides of
// nothing -- a double-width hole where a panel used to be.
test("a closed panel leaves the rail and nothing else", async ({ page }) => {
await app(page);
const g = await gutter(page);
const before = (await boxes(page, ".cx-workspace-main"))[0];
await page.locator(".cx-workspace-rail-left button").click();
@@ -110,6 +116,45 @@ test("a closed panel leaves one gutter behind it, not two", async ({ page }) =>
const [rail] = await boxes(page, ".cx-workspace-rail-left");
const [centre] = await boxes(page, ".cx-workspace-main");
expect(centre.x - rail.r, "the closed panel left a double gap").toBe(g);
expect(centre.x - rail.r, "a closed panel left a hole behind it").toBe(0);
expect(centre.w, "the centre did not take the room back").toBeGreaterThan(before.w);
});
test("the app's own surfaces have depth, and both themes get it", async ({ page }) => {
// Every band and every filled control was one flat colour with a 1px line
// round it, which is what made this read as a wireframe of itself. The
// gradients are derived from each theme's own elevation scale, so this has to
// hold in both -- a token that resolves in one theme and not the other is the
// exact fault the derived-token rule exists to prevent.
for (const theme of ["dark", "light"]) {
await page.addInitScript((t) => window.localStorage.setItem("funpack_theme", t), theme);
await app(page);
await expect.poll(() => page.evaluate(() =>
document.documentElement.getAttribute("data-theme"))).toBe(theme);
const painted = await page.evaluate(() => ({
head: getComputedStyle(document.querySelector(".cx-panel-head")).backgroundImage,
body: getComputedStyle(document.body).backgroundImage,
primary: getComputedStyle(document.querySelector(".cx-btn-primary")).backgroundImage,
glow: getComputedStyle(document.querySelector(".cx-btn-primary")).boxShadow,
grain: getComputedStyle(document.querySelector(".cx-frame"), "::after").opacity,
}));
expect(painted.head, `${theme}: a band is a flat fill`).toContain("gradient");
expect(painted.body, `${theme}: the page is a flat fill`).toContain("gradient");
expect(painted.primary, `${theme}: the primary button is a flat fill`).toContain("gradient");
expect(painted.glow, `${theme}: the primary button throws no light`).not.toBe("none");
expect(Number(painted.grain), `${theme}: no grain`).toBeGreaterThan(0);
}
});
test("a zone is square and a card is not", async ({ page }) => {
await app(page);
const zone = await page.evaluate(() => {
const n = document.querySelector(".cx-zone");
const s = getComputedStyle(n);
return { radius: s.borderTopLeftRadius, shadow: s.boxShadow };
});
expect(zone.radius, "a region of the app has rounded corners").toBe("0px");
expect(zone.shadow, "a region of the app floats above itself").toBe("none");
});