diff --git a/app/catalogue/demos.js b/app/catalogue/demos.js index d7976ee6..2b20ec31 100644 --- a/app/catalogue/demos.js +++ b/app/catalogue/demos.js @@ -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: [ diff --git a/app/composer/elements/button.css b/app/composer/elements/button.css index 3f07684a..a16c4426 100644 --- a/app/composer/elements/button.css +++ b/app/composer/elements/button.css @@ -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); } diff --git a/app/composer/elements/gallery.css b/app/composer/elements/gallery.css index ea864c6e..c71555a2 100644 --- a/app/composer/elements/gallery.css +++ b/app/composer/elements/gallery.css @@ -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%; diff --git a/app/composer/elements/layout.css b/app/composer/elements/layout.css index 3184ed94..846920f3 100644 --- a/app/composer/elements/layout.css +++ b/app/composer/elements/layout.css @@ -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; } diff --git a/app/composer/elements/layout.js b/app/composer/elements/layout.js index 856a4e7a..fc1bf7c6 100644 --- a/app/composer/elements/layout.js +++ b/app/composer/elements/layout.js @@ -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. // diff --git a/app/composer/tokens/reset.css b/app/composer/tokens/reset.css index 802bd76b..4556a6f5 100644 --- a/app/composer/tokens/reset.css +++ b/app/composer/tokens/reset.css @@ -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; } diff --git a/app/composer/tokens/tokens.css b/app/composer/tokens/tokens.css index 6bebbbd7..85c1c19b 100644 --- a/app/composer/tokens/tokens.css +++ b/app/composer/tokens/tokens.css @@ -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. diff --git a/app/shell/layout.js b/app/shell/layout.js index 2d559daf..c83231da 100644 --- a/app/shell/layout.js +++ b/app/shell/layout.js @@ -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, diff --git a/app/tests-layout/alignment.spec.js b/app/tests-layout/alignment.spec.js index be90d568..a88fafe3 100644 --- a/app/tests-layout/alignment.spec.js +++ b/app/tests-layout/alignment.spec.js @@ -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"); +});