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