From 16eec81a03fa1c8f648b57266bd6eaf2e22b13a8 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:17:22 +0300 Subject: [PATCH] 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. --- app/catalogue/demos.js | 5 ++ app/composer/elements/button.css | 9 ++-- app/composer/elements/gallery.css | 5 +- app/composer/elements/layout.css | 60 ++++++++++++++++++------ app/composer/elements/layout.js | 29 ++++++++++-- app/composer/tokens/reset.css | 21 ++++++++- app/composer/tokens/tokens.css | 38 ++++++++++++++++ app/shell/layout.js | 26 ++++++----- app/tests-layout/alignment.spec.js | 73 ++++++++++++++++++++++++------ 9 files changed, 216 insertions(+), 50 deletions(-) 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"); +});