From 03d1552746b2ba4ba599317da13049a942feab6c Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 2 Sep 2026 01:21:41 +0200 Subject: [PATCH] jobq-graph: animate new-node mount and state-change flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three motion gaps mara flagged on the swarm-ui jobs graph (the third, node status changes as a tree of nesting divs rather than a node-link diagram, has no edges to animate today — see the issue thread for that scoping correction). - New node mount: .jg-node gets a fade+slide-in keyframe. No JS change needed — Preact only creates a new .jg-node DOM node when its key (the node id) is genuinely new, so this only plays on first appearance, not every fetch re-render. - State change flash: NodeView tracks each node's previous state via a ref; on a real change it adds .jg-state-flash to the glyph span (removed on animationend), driving a scale pulse. Both follow the same three-rule motion-guard shape as Shell.css's shell-page-enter / LinksMenu.css's links-menu-popover-enter. --- .../shared/src/jobq-graph/JobqGraph.tsx | 21 ++++++- .../shared/src/jobq-graph/jobq-graph.css | 57 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx b/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx index 8c335ba4..d21315fe 100644 --- a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx +++ b/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx @@ -28,7 +28,7 @@ // loader is `text` (for unrelated shadow-DOM components' CSS-as-string // needs), and that loader is global per call, not per-module. -import { useState, useEffect, useCallback } from 'preact/hooks'; +import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; // Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no // `rename_all`) — see that enum's own doc comment for why it's kept in @@ -159,11 +159,26 @@ function NodeView({ }) { const glyph = STATE_GLYPH[n.state] || '?'; const showCancel = cancellable && CANCELLABLE_STATES.has(n.state); + // Flash the state glyph on a genuine state change (Pending → Running, + // etc.), not on mount — `prevState` starts at the node's own initial + // state, so the first render never flashes. Does not fire on every + // fetch: `n` is a fresh object each snapshot (see `buildTree`), but + // its `.state` value is only actually different when the job's real + // state moved. + const prevState = useRef(n.state); + const [flashing, setFlashing] = useState(false); + useEffect(() => { + if (prevState.current !== n.state) { + prevState.current = n.state; + setFlashing(true); + } + }, [n.state]); return (
- + setFlashing(false)}> {glyph} {' '} diff --git a/frontend/packages/shared/src/jobq-graph/jobq-graph.css b/frontend/packages/shared/src/jobq-graph/jobq-graph.css index 39560d6a..40dfaf4a 100644 --- a/frontend/packages/shared/src/jobq-graph/jobq-graph.css +++ b/frontend/packages/shared/src/jobq-graph/jobq-graph.css @@ -40,8 +40,36 @@ gap: 0.15em; } +/* Mount-triggered fade+slide, not a state transition — Preact only + creates a new `.jg-node` DOM node when its `key` (the node id) is + genuinely new, so this only plays once per node, the first time it + appears in a fetched snapshot. Same shape as Shell.css's + `shell-page-enter` / LinksMenu.css's `links-menu-popover-enter` — + see either for the three-rule motion-guard this mirrors. */ .jg-node { margin: 0; + animation: jg-node-enter 160ms ease; +} +@keyframes jg-node-enter { + from { + opacity: 0; + transform: translateY(-0.2em); + } + to { + opacity: 1; + transform: none; + } +} +@media (prefers-reduced-motion: reduce) { + .jg-node { + animation: none; + } +} +:root[data-motion='reduce'] .jg-node { + animation: none; +} +:root[data-motion='allow'] .jg-node { + animation: jg-node-enter 160ms ease; } /* Nested groups indent + get a guide line, rather than a hand-rolled @@ -73,6 +101,35 @@ .jg-state-cancelled { color: var(--muted); text-decoration: line-through; } .jg-state-skipped { color: var(--muted); opacity: 0.5; } +/* Short-lived pulse applied by `NodeView` (JobqGraph.tsx) exactly when + a node's own `state` value changes on an existing DOM node — a mount + keyframe (`.jg-node`'s `jg-node-enter`, above) doesn't retrigger for + this case since the node isn't remounting, just updating. Class is + removed on `animationend` (see NodeView), so the scale never gets + stuck applied. */ +.jg-state-flash { + animation: jg-state-flash 350ms ease; +} +@keyframes jg-state-flash { + from { + transform: scale(1.6); + } + to { + transform: none; + } +} +@media (prefers-reduced-motion: reduce) { + .jg-state-flash { + animation: none; + } +} +:root[data-motion='reduce'] .jg-state-flash { + animation: none; +} +:root[data-motion='allow'] .jg-state-flash { + animation: jg-state-flash 350ms ease; +} + .jg-label { color: var(--fg); }