Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
1785c7d
delphi: refresh ws/software/application-software/argos/argos-dev from…
github-actions[bot] Oct 18, 2026
a458bc2
delphi: sync .delphi/ from main
github-actions[bot] Oct 18, 2026
d041fd9
delphi: sync inherited files from main
github-actions[bot] Oct 18, 2026
8c21e54
delphi: refresh ws/software/application-software/argos/argos-dev from…
github-actions[bot] Oct 18, 2026
a399b6b
delphi: sync inherited files from main
github-actions[bot] Oct 18, 2026
1919678
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 18, 2026
6f8f194
delphi: sync .delphi/ from main
github-actions[bot] Oct 18, 2026
8524917
delphi: sync inherited files from main
github-actions[bot] Oct 18, 2026
c909a5a
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 18, 2026
d5a80d4
delphi: sync .delphi/ from main
github-actions[bot] Oct 18, 2026
e51b2f1
delphi: sync inherited files from main
github-actions[bot] Oct 18, 2026
944d864
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 18, 2026
4189ef4
delphi: sync .delphi/ from main
github-actions[bot] Oct 18, 2026
bfb6e01
delphi: sync inherited files from main
github-actions[bot] Oct 18, 2026
2991d5b
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 18, 2026
5abbfc6
delphi: sync .delphi/ from main
github-actions[bot] Oct 18, 2026
297969e
delphi: sync inherited files from main
github-actions[bot] Oct 18, 2026
84deba4
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 19, 2026
7a915b5
delphi: sync inherited files from main
github-actions[bot] Oct 19, 2026
a94a522
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 19, 2026
17c5eb1
delphi: sync inherited files from main
github-actions[bot] Oct 19, 2026
40c9459
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 19, 2026
4c4ae10
delphi: sync .delphi/ from main
github-actions[bot] Oct 19, 2026
47a188f
delphi: sync inherited files from main
github-actions[bot] Oct 19, 2026
ceb8559
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 19, 2026
d23edb0
delphi: sync inherited files from main
github-actions[bot] Oct 19, 2026
daf8043
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 19, 2026
86a44a1
delphi: sync inherited files from main
github-actions[bot] Oct 19, 2026
956ab72
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 25, 2026
6e6f250
delphi: sync .delphi/ from main
github-actions[bot] Oct 25, 2026
6ff225b
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 25, 2026
e34e77a
delphi: sync inherited files from main
github-actions[bot] Oct 25, 2026
2552fc5
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Oct 28, 2026
437621d
argos-dev: animation-debug skill
gauriprabhakar Nov 5, 2026
1629dd2
#960 - angular-client conventions for Tailwind tokens, recipes and pr…
claude Nov 8, 2026
682ad69
Merge pull request #88 from Northeastern-Electric-Racing/argos-dev-an…
bracyw Nov 9, 2026
7cfb581
delphi: refresh ws/s.app.argos.argos-dev from main
github-actions[bot] Nov 9, 2026
9f94099
delphi: propose ws/s.app.argos.argos-dev to main
github-actions[bot] Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <branch>` (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 <branch> "$BASE_URL/<page>" <animation-name> <click|hover> '<selector>' [<window-ms> [<step-ms> [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/<branch>/<animation-name>/NN-<ms>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='<selector>;;<selector>'` 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=<branch> close` ends it.
Original file line number Diff line number Diff line change
@@ -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`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# capture.sh <branch> <url> <name> <click|hover> <selector> [<window-ms> [<step-ms> [clock|live]]]: open the page in
# <branch>'s playwright-cli session (opening it if needed), perform the action on the selector, and save a
# screenshot every <step-ms> (default 10) over <window-ms> (default 100) to pictures/<branch>/<name>/NN-<ms>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='<selector>;;<selector>' 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 <branch> <url> <name> <click|hover> <selector> [<window-ms> [<step-ms> [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
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<ui-icon>` 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 `<body>`.
- 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.
Expand Down