Kit: responsive system (scales, wrapping toolbars, bounded windows), viewport test, docs
Lane sizes and the window margin become tokens; toolbars wrap by default; windows are capped to the viewport; a ratchet test stops element CSS gaining literal pixel sizes. app/e2e runs the real app in Chromium and Firefox at eight screen sizes (npm run test:viewport). docs/ui-design-guidelines.md gains "Responsive by construction". Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
6496dcf982
commit
d8a906a866
@@ -0,0 +1,47 @@
|
||||
// A stand-in for the FunUI server, just enough for the browser tests: serves app/ui under /fun/app/ui/ and answers
|
||||
// the API with a small fixture project, a pipeline with a sampler, and Training switched on. Anything else answers {}.
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ui");
|
||||
const TYPES = { ".js": "text/javascript", ".css": "text/css", ".html": "text/html", ".woff2": "font/woff2", ".svg": "image/svg+xml" };
|
||||
const json = (v) => JSON.stringify(v);
|
||||
|
||||
const PROJECT = {
|
||||
id: "p1", name: "Demo cut", num_frames_per_scene: 97, frame_rate: 25, scene_renders: {}, timeline_manually_ordered: false,
|
||||
scenes: ["A fox runs through snow", "Close-up of the fox", "Wide shot, aurora sky"].map((text, i) => ({ id: `s${i + 1}`, text, frames_mode: "project" })),
|
||||
};
|
||||
const PIPELINE = {
|
||||
queueable: true,
|
||||
slots: [{ id: "k1", node: "KSampler", inputs: { steps: 20, cfg: 6, sampler_name: "euler" },
|
||||
roles: ["steps:Steps", "cfg:CFG scale", "sampler_name:Sampler"].map((r) => ({ at: "generation.sampling", input: r.split(":")[0], label: r.split(":")[1] })) }],
|
||||
};
|
||||
const NODES = { KSampler: { widgets: [
|
||||
{ name: "steps", type: "INT", min: 1, max: 100, default: 20 },
|
||||
{ name: "cfg", type: "FLOAT", min: 0, max: 30, step: 0.1, default: 6 },
|
||||
{ name: "sampler_name", type: "COMBO", options: ["euler", "dpmpp_2m", "heun"], default: "euler" }] } };
|
||||
const ANSWERS = {
|
||||
"/fun/api/projects": { projects: [{ id: "p1", name: "Demo cut", updated: 1 }] },
|
||||
"/fun/api/projects/p1": PROJECT,
|
||||
"/fun/api/pipeline": PIPELINE,
|
||||
"/fun/api/nodes": { nodes: NODES },
|
||||
"/api/m/training_lora/state": { enabled: true },
|
||||
"/fun/api/m/training_lora/state": { enabled: true },
|
||||
};
|
||||
|
||||
export function start(port = 0) {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = req.url.split("?")[0];
|
||||
if (url === "/" || url.startsWith("/fun/app/ui/")) {
|
||||
const file = path.join(ROOT, url === "/" ? "index.html" : url.slice("/fun/app/ui/".length));
|
||||
if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
|
||||
try { const body = fs.readFileSync(file); res.writeHead(200, { "content-type": TYPES[path.extname(file)] || "application/octet-stream" }); return res.end(body); }
|
||||
catch { res.writeHead(404); return res.end(); }
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(json(ANSWERS[url] || {}));
|
||||
});
|
||||
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve({ server, url: `http://127.0.0.1:${server.address().port}/` })));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// The app at the screens people really have, in a real browser: no page overflow, every window inside the viewport,
|
||||
// both timeline lanes visible. Not part of `npm test` (it needs browsers): run it with `npm run test:viewport`.
|
||||
// FUNUI_BROWSERS=chromium,firefox picks the engines (default: every one that is installed).
|
||||
import playwright from "playwright";
|
||||
import { start } from "./stub.mjs";
|
||||
|
||||
const SIZES = [[1280, 720], [1024, 1024], [1366, 768], [1920, 1080], [2560, 1440], [768, 1024], [720, 1280], [1080, 1920]];
|
||||
const WINDOWS = [["Settings", /^Settings$/], ["Log", /^Log$/], ["Composer", /Composer/], ["Sampler", /Sampler/], ["Training", /Training/]];
|
||||
const wanted = (process.env.FUNUI_BROWSERS || "chromium,firefox").split(",");
|
||||
|
||||
const inspect = () => {
|
||||
const box = (e) => e.getBoundingClientRect();
|
||||
const seen = [...document.querySelectorAll(".cx-modal, .cx-popover, .cx-window")].filter((e) => box(e).width > 0);
|
||||
const out = seen.map((e) => ({ name: e.className.toString().split(" ")[0], r: box(e) }));
|
||||
const nle = document.querySelector(".cx-nle"), lanes = [...document.querySelectorAll(".cx-nle-gutter-lane")];
|
||||
return {
|
||||
over: [document.documentElement.scrollWidth - innerWidth, document.documentElement.scrollHeight - innerHeight],
|
||||
windows: out.filter(({ r }) => r.left < -1 || r.top < -1 || r.right > innerWidth + 1 || r.bottom > innerHeight + 1).map(({ name, r }) => `${name} ${Math.round(r.left)},${Math.round(r.top)}..${Math.round(r.right)},${Math.round(r.bottom)}`),
|
||||
lanes: lanes.length < 2 ? ["fewer than two lanes"] : lanes.filter((l) => box(l).bottom > box(nle).bottom + 1).map((l) => `lane ends ${Math.round(box(l).bottom)}, timeline ${Math.round(box(nle).bottom)}`),
|
||||
};
|
||||
};
|
||||
|
||||
const { server, url } = await start(0);
|
||||
const problems = [];
|
||||
let ran = 0;
|
||||
for (const name of wanted) {
|
||||
let browser;
|
||||
try { browser = await playwright[name].launch(); } catch { console.log(`skipping ${name}: not installed`); continue; }
|
||||
for (const [w, h] of SIZES) {
|
||||
const page = await browser.newPage({ viewport: { width: w, height: h } });
|
||||
const note = (what) => problems.push(`${name} ${w}x${h}: ${what}`);
|
||||
await page.goto(url);
|
||||
await page.getByText(/Continue with/).click();
|
||||
await page.waitForTimeout(800);
|
||||
const main = await page.evaluate(inspect);
|
||||
if (main.over[0] > 0 || main.over[1] > 0) note(`page overflows by ${main.over}`);
|
||||
main.lanes.forEach((m) => note(m));
|
||||
for (const [label, text] of WINDOWS) {
|
||||
await page.getByText(text).first().click({ timeout: 1500 }).catch(() => note(`${label} cannot be opened`));
|
||||
await page.waitForTimeout(500);
|
||||
(await page.evaluate(inspect)).windows.forEach((m) => note(`${label} window outside the viewport: ${m}`));
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await page.close();
|
||||
ran += 1;
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
server.close();
|
||||
console.log(problems.length ? problems.join("\n") : `ok: ${ran} browser/size runs, no findings`);
|
||||
process.exit(problems.length || !ran ? 1 : 0);
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"type": "module",
|
||||
"description": "Dev-only. Users never run npm: the app is served as plain ES modules with no build step.",
|
||||
"scripts": {
|
||||
"test": "node --test --test-timeout=60000"
|
||||
"test": "node --test --test-timeout=60000",
|
||||
"test:viewport": "node e2e/viewports.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
.cx-toggle-face { align-items: center; justify-content: space-between; gap: var(--sp-6); }
|
||||
/* min-inline-size, or a long word in a hint pushes the row wider than the panel
|
||||
it is in instead of wrapping. */
|
||||
.cx-check-text { display: flex; flex-direction: column; gap: 2px; min-inline-size: 0; }
|
||||
.cx-check-text { display: flex; flex-direction: column; gap: var(--sp-1); min-inline-size: 0; }
|
||||
.cx-check-label { font-size: var(--fs-4); line-height: var(--lh-4); color: var(--text); }
|
||||
.cx-checklist {
|
||||
display: flex; flex-direction: column;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
.cx-cell-badge { left: var(--sp-1); }
|
||||
.cx-cell-duration { right: var(--sp-1); font-variant-numeric: tabular-nums; }
|
||||
.cx-cell-flags { position: absolute; top: 2px; right: 2px; display: flex; gap: 2px; pointer-events: none; }
|
||||
.cx-cell-flags { position: absolute; top: 2px; right: 2px; display: flex; gap: var(--sp-1); pointer-events: none; }
|
||||
.cx-cell-flag { font-size: 11px; font-weight: 700; line-height: 1; padding: 2px 4px; border-radius: var(--r-1); background: var(--accent); color: var(--void); }
|
||||
.cx-cell-name {
|
||||
font-size: clamp(11px, calc(var(--cell, 96px) * .115), 13px);
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
of the icon and the label. Centred on the icon: with the hint gone (a
|
||||
nav row now, not a compact picker's two-line entry) top-aligning the two
|
||||
just looks like a mistake. */
|
||||
.cx-filter-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; justify-content: center; }
|
||||
.cx-filter-text { display: flex; flex-direction: column; gap: var(--sp-1); flex: 1; min-width: 0; justify-content: center; }
|
||||
.cx-filter-label { display: block; }
|
||||
.cx-filter-hint { display: block; font-size: var(--fs-7); color: var(--muted); line-height: var(--lh-snug); }
|
||||
.cx-filter-empty { padding: var(--sp-5); font-size: var(--fs-6); color: var(--faint); text-align: center; }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
padding: var(--sp-6) 0; border-bottom: var(--bw) solid var(--line-2);
|
||||
}
|
||||
.cx-settings-row:last-child { border-bottom: 0; }
|
||||
.cx-settings-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.cx-settings-text { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; }
|
||||
.cx-settings-label { font-size: var(--fs-4); line-height: var(--lh-4); color: var(--text); }
|
||||
.cx-settings-control {
|
||||
flex: 0 0 var(--control-col); min-inline-size: 0;
|
||||
@@ -136,7 +136,8 @@
|
||||
.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; align-items: center; gap: var(--sp-2); min-inline-size: 0; }
|
||||
/* Wraps by default, so a new button never pushes a row wider than its zone. */
|
||||
.cx-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2); min-inline-size: 0; }
|
||||
.cx-toolbar-trailing {
|
||||
margin-inline-start: auto; display: flex; align-items: center; gap: var(--sp-3);
|
||||
font-size: var(--fs-6); color: var(--muted);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.cx-nle { block-size: 100%; min-block-size: 0; overflow: auto; background: var(--elev-1); }
|
||||
.cx-nle-stage { display: flex; min-inline-size: 100%; position: relative; }
|
||||
.cx-nle-gutter {
|
||||
position: sticky; inset-inline-start: 0; z-index: 3; flex: none; inline-size: 56px;
|
||||
position: sticky; inset-inline-start: 0; z-index: 3; flex: none; inline-size: var(--lane-gutter-w);
|
||||
background: var(--elev-1); border-inline-end: var(--bw) solid var(--line);
|
||||
}
|
||||
.cx-nle-gutter-ruler { block-size: var(--sp-9); border-block-end: var(--bw) solid var(--line); }
|
||||
@@ -10,9 +10,9 @@
|
||||
display: flex; align-items: center; justify-content: flex-end; padding-inline-end: var(--sp-3);
|
||||
font-size: var(--fs-6); color: var(--faint); border-block-end: var(--bw) solid var(--line);
|
||||
}
|
||||
.cx-nle-lane-video { block-size: 96px; }
|
||||
.cx-nle-lane-audio { block-size: 44px; }
|
||||
.cx-nle-lane-overlay { block-size: 40px; }
|
||||
.cx-nle-lane-video { block-size: var(--lane-h-video); }
|
||||
.cx-nle-lane-audio { block-size: var(--lane-h-audio); }
|
||||
.cx-nle-lane-overlay { block-size: var(--lane-h-overlay); }
|
||||
|
||||
.cx-nle-content { position: relative; flex: none; min-inline-size: 0; }
|
||||
.cx-nle-ruler { position: relative; block-size: var(--sp-9); border-block-end: var(--bw) solid var(--line); }
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-5);
|
||||
padding: var(--sp-8) var(--sp-9) var(--sp-7); border-bottom: var(--bw) solid var(--line);
|
||||
}
|
||||
.cx-modal-titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.cx-modal-titles { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; }
|
||||
.cx-modal-title { font-family: var(--font-display); font-size: var(--fs-2); font-weight: var(--fw-semibold); }
|
||||
/* A column, so a body that wants to fill the card can. Without it the body
|
||||
sized to its content and the card scrolled as a whole -- which put the group
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
.cx-choice-list { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.cx-choice-row {
|
||||
display: flex; flex-direction: column; gap: 2px; text-align: left;
|
||||
display: flex; flex-direction: column; gap: var(--sp-1); text-align: left;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border: var(--bw) solid var(--line); border-radius: var(--r-3);
|
||||
background: var(--elev-2); color: var(--text); cursor: pointer;
|
||||
@@ -135,6 +135,8 @@
|
||||
background: var(--elev-3); color: var(--text);
|
||||
border: var(--bw) solid var(--line); border-radius: var(--r-4);
|
||||
box-shadow: var(--shadow-3); overflow: hidden;
|
||||
/* Whatever size was remembered or asked for, never larger than the screen. */
|
||||
max-inline-size: 100vw; max-block-size: 100dvh;
|
||||
}
|
||||
.cx-window-bar {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
|
||||
|
||||
@@ -56,7 +56,7 @@ body { -webkit-font-smoothing: antialiased; letter-spacing: -.003em; }
|
||||
.cx-nle-clip-title { font-weight: var(--fw-medium); }
|
||||
|
||||
/* toolbars inside a zone wrap instead of overlapping; readouts never break mid-phrase */
|
||||
.cx-zone .cx-toolbar { flex-wrap: wrap; row-gap: var(--sp-2); }
|
||||
.cx-zone .cx-toolbar { row-gap: var(--sp-2); }
|
||||
.cx-zone .cx-toolbar-trailing { flex-wrap: wrap; }
|
||||
.cx-zone .cx-toolbar .cx-hint, .cx-zone .cx-toolbar .cx-t { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.cx-zone .cx-toolbar .cx-hint { padding-inline: var(--sp-3); }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
padding: var(--sp-6) var(--sp-5); gap: var(--sp-5);
|
||||
}
|
||||
.cx-settings-rail .cx-search { inline-size: 100%; }
|
||||
.cx-settings-rail .cx-filter-list { gap: 2px; }
|
||||
.cx-settings-rail .cx-filter-list { gap: var(--sp-1); }
|
||||
.cx-settings-rail .cx-filter-row { gap: var(--sp-5); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-4); font-size: var(--fs-4); }
|
||||
.cx-settings-rail .cx-filter-icon { inline-size: 24px; block-size: 24px; border-radius: 6px; font-size: var(--fs-5); margin: 0; }
|
||||
.cx-settings-rail .cx-filter-row.cx-on { background: var(--accent); color: var(--on-accent); }
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
.cx-wheel-item {
|
||||
position: absolute; transform: translate(-50%, -50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-1);
|
||||
width: 70px; padding: var(--sp-1) 0; line-height: 1.15; overflow-wrap: anywhere; /* long names wrap inside the ring instead of sticking out of it */
|
||||
background: transparent; border: 0; color: var(--muted);
|
||||
font-size: var(--fs-7); cursor: pointer; text-align: center;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Sizes and gaps come from the scale in tokens.css (--sp-*, --ctl-h-*, --lane-*, ...), not from literals in element
|
||||
// rules: a literal is a number nobody can change in one place. This is a ratchet: the count of literal pixel values
|
||||
// in element CSS may go down, never up. Lowering it is the point; when it does, lower LITERAL_PX here too.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "elements");
|
||||
const LITERAL_PX = 118;
|
||||
|
||||
test("element CSS does not grow new literal pixel sizes", () => {
|
||||
let found = 0;
|
||||
for (const f of readdirSync(dir).filter((n) => n.endsWith(".css"))) {
|
||||
const css = readFileSync(join(dir, f), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const decl of css.split(/[;{}]/)) {
|
||||
if (/^\s*--/.test(decl)) continue; // a token definition is where a number belongs
|
||||
found += (decl.match(/(?<![\w.-])(?:[2-9]|\d{2,})(?:\.\d+)?px\b/g) || []).length; // 1px lines and 0 are borders and resets
|
||||
}
|
||||
}
|
||||
assert.ok(found <= LITERAL_PX, `${found} literal px values in element CSS (limit ${LITERAL_PX}): use a token from tokens.css`);
|
||||
if (found < LITERAL_PX) console.log(`lower LITERAL_PX to ${found}`);
|
||||
});
|
||||
@@ -76,6 +76,16 @@
|
||||
its widest OPTION, so one row holding a list of checkpoint filenames was
|
||||
264px wide among four that were 140. */
|
||||
--control-col: 180px;
|
||||
|
||||
/* ── responsive system ───────────────────────────────────────────────
|
||||
Breakpoints. A custom property cannot sit in a @media query, so these are
|
||||
the agreed numbers, written out in the rules that use them:
|
||||
narrow <= 1250px regions give up width, the timeline gets taller to hold wrapped rows
|
||||
compact <= 900px side zones shrink to their floor
|
||||
Everything below the first breakpoint is built to wrap, never to clip. */
|
||||
--win-margin: var(--sp-7); /* the room a window or popover keeps from the viewport edge */
|
||||
--lane-gutter-w: 56px; /* timeline: label column */
|
||||
--lane-h-video: 96px; --lane-h-audio: 44px; --lane-h-overlay: 40px;
|
||||
}
|
||||
|
||||
/* ── dark: the cutting-room palette (default) ─────────────────────────── */
|
||||
|
||||
@@ -65,6 +65,34 @@ Never carry meaning by colour alone. A chip says its state in words, an error ha
|
||||
- Test every new element in both themes in the catalogue.
|
||||
- A custom property used but never defined resolves to nothing without any error, so check that every `var(--x)` you write exists.
|
||||
|
||||
## Responsive by construction
|
||||
|
||||
The kit is built the way a grid system is: you pick a step from a scale, and the layout primitives size and wrap themselves. A new button, field or panel should need no CSS of its own to look right at any window size.
|
||||
|
||||
**The scales** (all in `composer/tokens/tokens.css`)
|
||||
|
||||
| Scale | Tokens | Rule |
|
||||
|---|---|---|
|
||||
| Spacing | `--sp-0` .. `--sp-11` | Every gap and padding. Panels pad with `--sp-7` (16px), rows inside a toolbar sit `--sp-2` apart. |
|
||||
| Control height | `--ctl-h-sm` .. `--ctl-h-xl` | One per row. Never set a height on a button. |
|
||||
| Edge room | `--gutter`, `--win-margin` | The space between regions, and the room a window keeps from the screen edge. |
|
||||
| Timeline | `--lane-h-video`, `--lane-h-audio`, `--lane-h-overlay`, `--lane-gutter-w` | Lane sizes live in one place. |
|
||||
|
||||
**Breakpoints.** A custom property cannot be used in `@media`, so the numbers are written in the rules and listed once in `tokens.css`: narrow is 1250px and under (the timeline gets taller because its header and toolbar wrap onto more rows), compact is 900px and under. The frame's proportions are in `app/ui/frame.css` (`--media-w`, `--properties-w`, `--timeline-h`).
|
||||
|
||||
**Primitives that adapt on their own**
|
||||
|
||||
- `toolbar` wraps by default. Add items freely; they flow to a second row instead of widening the zone.
|
||||
- `segmented` wraps its choices to a second line in a narrow column, so none is clipped.
|
||||
- `stack` is a column with a gap from the scale; `region.stack` with `fill` takes the room it is given and scrolls inside.
|
||||
- `panel` bodies pad with the scale and carry no bottom spacer; the padding is the clearance, also at the end of a scroll.
|
||||
- Windows (`floating.window`, modals, popovers) are bounded by the viewport in both axes and come fully back on screen when it shrinks.
|
||||
- Settings rows in a stack, a modal or a popover form one card with seams; do not add your own spacing around them.
|
||||
|
||||
**Checking it.** `npm test` has a ratchet (`rule_kit_uses_the_scale.test.js`) that fails when element CSS gains a literal pixel size. `cd app && npm run test:viewport` opens the real app in Chromium and Firefox (whichever are installed) at 1280x720, 1024x1024, 1366x768, 1920x1080, 2560x1440 and the vertical 768x1024, 720x1280, 1080x1920, against a stub server (`app/e2e/stub.mjs`), and fails on page overflow, a window outside the viewport, or a timeline lane cut off. It needs Playwright browsers, so it is not part of `npm test`.
|
||||
|
||||
**Adding an element so it adapts.** Use `cx-` classes and tokens only. Let it be a flex or grid child that can shrink (`min-inline-size: 0`). Put a row of controls in a `toolbar`, not in your own flex box. If it needs a size, add a token to `tokens.css` and use that. Then run the viewport test and look at the element in the catalogue at a narrow width.
|
||||
|
||||
## Layout and density
|
||||
|
||||
- Build layout with `region.stack`, `splitPane`, `panel`, `toolbar`, `group` and `workspace`, not with ad hoc CSS.
|
||||
|
||||
Reference in New Issue
Block a user