diff --git a/app/e2e/stub.mjs b/app/e2e/stub.mjs new file mode 100644 index 00000000..9b83a394 --- /dev/null +++ b/app/e2e/stub.mjs @@ -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}/` }))); +} diff --git a/app/e2e/viewports.mjs b/app/e2e/viewports.mjs new file mode 100644 index 00000000..42151eb9 --- /dev/null +++ b/app/e2e/viewports.mjs @@ -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); diff --git a/app/package.json b/app/package.json index 19b239da..662b3a93 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/ui/composer/elements/choice.css b/app/ui/composer/elements/choice.css index ff1d6670..1f790584 100644 --- a/app/ui/composer/elements/choice.css +++ b/app/ui/composer/elements/choice.css @@ -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; diff --git a/app/ui/composer/elements/gallery.css b/app/ui/composer/elements/gallery.css index 7b424ab6..a50fffd9 100644 --- a/app/ui/composer/elements/gallery.css +++ b/app/ui/composer/elements/gallery.css @@ -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); diff --git a/app/ui/composer/elements/input.css b/app/ui/composer/elements/input.css index fb954274..cdbdb9ca 100644 --- a/app/ui/composer/elements/input.css +++ b/app/ui/composer/elements/input.css @@ -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; } diff --git a/app/ui/composer/elements/layout.css b/app/ui/composer/elements/layout.css index e7d39c9d..ef8e645f 100644 --- a/app/ui/composer/elements/layout.css +++ b/app/ui/composer/elements/layout.css @@ -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); diff --git a/app/ui/composer/elements/nle.css b/app/ui/composer/elements/nle.css index e65568d8..62ab9db9 100644 --- a/app/ui/composer/elements/nle.css +++ b/app/ui/composer/elements/nle.css @@ -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); } diff --git a/app/ui/composer/elements/overlay.css b/app/ui/composer/elements/overlay.css index 1f7a4197..a6e36cd0 100644 --- a/app/ui/composer/elements/overlay.css +++ b/app/ui/composer/elements/overlay.css @@ -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); diff --git a/app/ui/composer/elements/polish.css b/app/ui/composer/elements/polish.css index 76f8887f..03f31f48 100644 --- a/app/ui/composer/elements/polish.css +++ b/app/ui/composer/elements/polish.css @@ -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); } diff --git a/app/ui/composer/elements/settings.css b/app/ui/composer/elements/settings.css index 23b32e66..02344a20 100644 --- a/app/ui/composer/elements/settings.css +++ b/app/ui/composer/elements/settings.css @@ -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); } diff --git a/app/ui/composer/elements/wheel.css b/app/ui/composer/elements/wheel.css index fb6009b4..971bdc8f 100644 --- a/app/ui/composer/elements/wheel.css +++ b/app/ui/composer/elements/wheel.css @@ -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; diff --git a/app/ui/composer/tests/rule_kit_uses_the_scale.test.js b/app/ui/composer/tests/rule_kit_uses_the_scale.test.js new file mode 100644 index 00000000..8ccdaef4 --- /dev/null +++ b/app/ui/composer/tests/rule_kit_uses_the_scale.test.js @@ -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(/(?