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); }