diff --git a/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/SKILL.md b/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/SKILL.md new file mode 100644 index 0000000..d6cdc99 --- /dev/null +++ b/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/SKILL.md @@ -0,0 +1,33 @@ +--- +name: animation-debug +description: Debug an Argos UI change that adds or changes an animation or transition, by capturing it frame by frame with Playwright and reviewing the frames. Use while developing, after changing Angular animations, CSS transitions, or anything that moves or fades; not for PR screenshots (open-pr). +user-invocable: true +--- + +A screenshot shows one instant, and Playwright can't take them fast enough (30-100 ms each) to catch a 100 ms animation. So after the trigger, the script pauses the page's animations and steps their time, which gives the same frames on every run, and before/after comparisons mean something. When nothing is animating, or you ask for it, it takes screenshots in real time instead, so any trigger gives you a series. The frames are for debugging; they're never committed. + +1. **Run the app** with `run-local ` (the ticket's worktree, from `new-worktree`). Its client URL is `BASE_URL`. +2. **Find the trigger:** the page URL, what to click or hover (a Playwright selector, such as `text=BMS` or `role=button[name='Pause']`), and a short kebab-case name for the animation. A route change is a click on the link that causes it. +3. **Capture:** + + ```bash + bash scripts/capture.sh "$BASE_URL/" '' [ [ [clock|live]]] + ``` + + Defaults are a frame every 10 ms over 100 ms (11 frames); both are settings. Set the window to the animation's duration (an Angular `animate('400ms')` needs 400) and widen the step for a long one. It saves `pictures///NN-ms.png`, replacing earlier frames of that name, and exits 1 with a `FAIL` line only if a click or selector fails. The first line says how it captured: `of N animation(s)` means it froze them; `in real time` means it did not. +4. **Review the frames in order**, first to last, and judge each against what the diff intends: + - Jank: a frame that jumps ahead of or behind its neighbours. + - Easing: spacing between frames matches the declared easing (even for `linear`, bunched at the ends for `ease-in-out`). + - Flicker: an element that vanishes or reappears between frames, or a flash of the end state in the first frame. + - Start and end: the first frame is the start state and the last is the declared end state, with no leftover offset or opacity. +5. **Report** the folder and one line per problem, naming the frame. + +Each frame is the whole page, so the navbar clock and live charts differ between runs even when an animation is frozen: judge the animated area, not the whole picture. To compare a change, capture the same trigger from `develop`'s client and from the branch's, under names like `nub-before` and `nub-after`. + +A trigger often sits behind other clicks (a tree to open, a menu). Give those as `SETUP=';;'` in the environment and they are clicked first, in order, from a fresh page load; only the last click, the trigger, starts the capture. + +For what pausing can't freeze, such as a chart drawing in as data arrives, pass `live` as the last argument (`… 3000 250 live`) to take real-time screenshots. Frames are named by when each was taken, they differ between runs, and each takes 30 ms or more, so a 10 ms step can't be met: the output reports the real spacing. Use `live` on a page that always has other animations running (the graph page has about 26 from page load), because the default would freeze those instead of what you clicked. + +For timer or `requestAnimationFrame` animations, which pausing can't reach, pass `clock` as the last argument (`… 100 10 clock`) to step the page's fake clock instead. The fake `requestAnimationFrame` ticks every 16 ms, so frames land on those ticks rather than exactly on the step. + +The browser session stays open after a capture; `playwright-cli -s= close` ends it. diff --git a/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/scripts/capture.js b/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/scripts/capture.js new file mode 100644 index 0000000..909a269 --- /dev/null +++ b/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/scripts/capture.js @@ -0,0 +1,94 @@ +// The animation-debug capture, for capture.sh to run with `playwright-cli run-code` (it replaces the args +// placeholder below with a JSON object). Screenshots can't keep up with a 100 ms animation (each takes +// 30-100 ms), so where it can, this freezes the animations and steps their time: frames are the same on +// every run. Otherwise it takes screenshots in real time. Returns text for capture.sh to print. +async (page) => { + const { url, out, action, target, windowMs, stepMs, mode, setup } = __ARGS__; + + // a frame every `stepMs` over the window: the first at 0, the last at the window (or the last step inside it) + const times = []; + for (let t = 0; t <= windowMs; t += stepMs) times.push(t); + const frames = times.length; + const frame = (i, t) => `${out}/${String(i + 1).padStart(2, '0')}-${t}ms.png`; + + // the whole page ('allow' leaves paused animations as set; 'disabled' would jump them to the end) + const shoot = (path) => page.screenshot({ path, animations: 'allow' }); + + // Date.now() can step backwards when the machine's clock re-syncs (it did on WSL), and run-code has no + // monotonic clock, so never let this one go back + let last = 0; + const now = () => (last = Math.max(last, Date.now())); + + let triggeredAt = 0; + const trigger = async () => { + for (const sel of setup) await page.locator(sel).first().click({ timeout: 15000 }); + const el = page.locator(target).first(); + await el.waitFor({ timeout: 15000 }); + if (action === 'hover') await el.hover(); + else await el.click(); + triggeredAt = now(); + }; + + // Real time, nothing paused: a screenshot every `stepMs`, or back to back when they take longer, each named + // by when it was taken (ms after the trigger), so frames differ between runs. For what pausing can't freeze, + // such as a chart drawing in as data arrives. + const burst = async (why) => { + const stamps = []; + for (let n = 0; n < frames; n++) { + stamps.push(now() - triggeredAt); + await shoot(frame(n, stamps[n])); + const wait = times[n + 1] - (now() - triggeredAt); + if (n + 1 < frames && wait > 0) await page.waitForTimeout(wait); + } + const gaps = stamps.slice(1).map((s, i) => s - stamps[i]); + return `captured ${frames} frames in real time${why}, named by when each was taken` + + (gaps.length ? `\n actual spacing ${Math.min(...gaps)}-${Math.max(...gaps)} ms (asked for ${stepMs})` : ''); + }; + + // forced real time, even if animations are running + if (mode === 'live') { + await page.goto(url); + await trigger(); + return burst(''); + } + + // timer or requestAnimationFrame animations, which pausing can't reach: step the page's fake clock instead + if (mode === 'clock') { + // The fake clock keeps running until paused, so pause from the page's own time. It is resumed before + // returning: a paused clock stops the page timers that playwright-cli waits on, and it would hang. + await page.clock.install(); + await page.goto(url); + // Align the pause to the fake requestAnimationFrame's 16 ms grid, which counts from install (it is + // performance.now()'s), or load time shifts every frame. + await page.clock.pauseAt(await page.evaluate(() => { + const offset = Date.now() - performance.now(); + return Math.round(Math.ceil((performance.now() + 1000) / 16) * 16 + offset); + })); + await trigger(); + for (const [i, t] of times.entries()) { + if (i > 0) await page.clock.runFor(t - times[i - 1]); + await shoot(frame(i, t)); + } + await page.clock.resume(); + return `captured ${frames} frames with the page clock, every ${stepMs} ms over ${windowMs} ms`; + } + + // Web Animations (Angular animations, CSS transitions and animations): pause every running animation, then + // set each one's currentTime to the step. A finished animation is rewound, not lost, because the list is + // kept on window. With none running, fall back to real time. + await page.goto(url); + await trigger(); + const count = await page.evaluate(async () => { + // two refreshes, so the animation the trigger caused has started (Angular starts them on the next pass) + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + window.__debugAnimations = document.getAnimations(); + window.__debugAnimations.forEach((a) => a.pause()); + return window.__debugAnimations.length; + }); + if (count === 0) return burst(' (no animation to freeze)'); + for (const [i, t] of times.entries()) { + await page.evaluate((ms) => window.__debugAnimations.forEach((a) => { a.currentTime = ms; }), t); + await shoot(frame(i, t)); + } + return `captured ${frames} frames of ${count} animation(s), every ${stepMs} ms over ${windowMs} ms`; +} diff --git a/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/scripts/capture.sh b/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/scripts/capture.sh new file mode 100755 index 0000000..590dae4 --- /dev/null +++ b/software/application-software/argos/workspaces/argos-dev/.claude/skills/animation-debug/scripts/capture.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# capture.sh [ [ [clock|live]]]: open the page in +# 's playwright-cli session (opening it if needed), perform the action on the selector, and save a +# screenshot every (default 10) over (default 100) to pictures///NN-ms.png, +# gitignored. It pauses the page's running animations and steps their time, so the frames are the same on every +# run; with none running it takes screenshots in real time instead. `live` forces real time (for data drawing in, +# which pausing can't freeze); `clock` steps the page's fake clock, for timer or requestAnimationFrame animations. +# SETUP=';;' in the environment clicks those first, in order, to reach the trigger (open a +# tree, a menu). Prints how it captured; exits 1 if a click or selector fails. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +usage="usage: capture.sh [ [ [clock|live]]]" +branch=${1:?$usage}; url=${2:?$usage}; name=${3:?$usage}; action=${4:?$usage}; target=${5:?$usage} +window=${6:-100}; step=${7:-10}; mode=${8:-} +root=${DELPHI_ROOT:?unset: start the session at the workspace root} + +# the name becomes a folder, so kebab-case only +[[ $name =~ ^[a-z0-9-]+$ ]] || { echo "capture: name must be kebab-case: $name" >&2; exit 1; } +[[ $action == click || $action == hover ]] || { echo "capture: action is click or hover: $action" >&2; exit 1; } +[[ $window =~ ^[0-9]+$ && $step =~ ^[1-9][0-9]*$ && $step -le $window && $((window / step)) -le 500 ]] || + { echo "capture: window-ms and step-ms are whole numbers, step at most the window, and at most 500 steps in it" >&2; exit 1; } +[[ -z $mode || $mode == clock || $mode == live ]] || { echo "capture: last argument is clock, live, or nothing: $mode" >&2; exit 1; } + +# earlier frames of the same name are replaced; one browser session per branch, opened only if it isn't already +out=$root/pictures/$branch/$name scratch=$root/.playwright-cli/$branch +rm -rf "$out"; mkdir -p "$out" "$scratch" +if ! playwright-cli list | grep -q "^- $branch:"; then + launch=; [ -x /opt/pw-browsers/chromium ] && launch=',"launchOptions":{"executablePath":"/opt/pw-browsers/chromium","headless":true}' + printf '{"browser":{"browserName":"chromium"%s},"outputDir":"%s"}\n' "$launch" "$root/pictures/$branch" >"$scratch/cli.config.json" + playwright-cli -s="$branch" open --config="$scratch/cli.config.json" "$url" >/dev/null +fi + +# capture.js with its __ARGS__ filled in as JSON (node, not sed, so quotes in a selector survive) +code=$(node -e ' + const [file, url, out, action, target, windowMs, stepMs, mode, setup] = process.argv.slice(1); + const args = { url, out, action, target, windowMs: +windowMs, stepMs: +stepMs, mode, setup: setup.split(";;").map((s) => s.trim()).filter(Boolean) }; + process.stdout.write(require("fs").readFileSync(file, "utf8").replace("__ARGS__", () => JSON.stringify(args))); +' "$here/capture.js" "${url%/}" "$out" "$action" "$target" "$window" "$step" "$mode" "${SETUP:-}") + +# `|| true`: a failed step must still be reported below, not end the script here +raw=$(playwright-cli -s="$branch" --raw run-code "$code" 2>&1 || true) +result=$(node -e ' + const s = process.argv[1]; + try { process.stdout.write(JSON.parse(s)); } catch { process.stdout.write("FAIL " + (s.replace(/^### Error\n/, "") || "no output from playwright-cli")); } +' "$raw") +echo "$result" +if grep -q '^FAIL' <<<"$result"; then exit 1; fi diff --git a/software/application-software/argos/workspaces/argos-dev/docs/CONTEXT.md b/software/application-software/argos/workspaces/argos-dev/docs/CONTEXT.md index ff98e26..ff394c7 100644 --- a/software/application-software/argos/workspaces/argos-dev/docs/CONTEXT.md +++ b/software/application-software/argos/workspaces/argos-dev/docs/CONTEXT.md @@ -76,10 +76,15 @@ _Avoid_: Simulator, encoder (without naming Calypso). Project-specific choices. Everything else follows current Angular and Rust defaults. -**angular-client** -- External `templateUrl` and `styleUrls`, never inline templates or styles. -- Icons, never emojis: Material Icons (`mat-icon` with `svgIcon`) for app-level UI, PrimeIcons (`pi pi-*`) inside PrimeNG components. Custom SVGs go in `src/assets/icons/`, registered with `MatIconRegistry`. -- Must pass AXE checks, WCAG AA minimum. +**angular-client** (UI approach: ADR 0007) +- External `templateUrl`, never inline templates or styles. A component is styled with utilities and has no stylesheet, or has `styleUrls` and no utilities, never both: unlayered CSS beats utilities. +- Tailwind v4 utilities on the tokens in `src/styles/theme.css` only (`bg-surface-2`, `text-fg-muted`): no default palette, arbitrary colours or `!` modifier. Font sizes come from the type scale; an arbitrary size only when it must follow the viewport. Dark-only, so never `dark:`; never `::ng-deep`. +- Text that carries meaning is at full strength: no opacity on it. White text only on `accent-strong`, `secondary` or darker. +- A look used in more than one place is a recipe in `src/ui/recipes/`: complete class lists in `tw()`, joined with `cx()`, never built from fragments. Shared behaviour is a primitive in `src/ui/` on Angular Aria or the CDK. +- Icons, never emojis: `` only, `name` for a Material Icons ligature or `svg` for our SVGs in `src/assets/icons/`, registered with `UiIconRegistry` in `app-context`. It is `aria-hidden` unless given a `label`. +- Third-party CSS goes in a cascade layer below utilities (`vendor`, `cdk` in `src/styles.css`), never unlayered. +- Move focus after an action with `injectFocusAfterRender` (`src/ui/focus/`), so it never drops to ``. +- Must pass AXE checks, WCAG AA minimum: text 4.5:1, control edges and states 3:1, targets at least 24×24 px. **scylla-server** - Controller → Service → DB: thin controllers, business logic in services, Diesel for DB access with batch upserts.