yadflow 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -2
- package/cli/doctor.mjs +7 -2
- package/cli/platform.mjs +17 -0
- package/package.json +1 -1
- package/skills/yad-checks/references/check-gates.md +11 -4
- package/skills/yad-checks/templates/checks/spec-link.sh +38 -13
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +9 -3
- package/skills/yad-docs/templates/app/src/App.tsx +69 -21
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +47 -0
- package/skills/yad-docs/templates/app/src/components/DetailPanel/RightPanel.tsx +9 -1
- package/skills/yad-docs/templates/app/src/components/Sidebar/StepList.tsx +0 -9
- package/skills/yad-docs/templates/app/src/components/shared/CommandPalette.tsx +6 -3
- package/skills/yad-docs/templates/app/src/hooks/useAnimationQueue.ts +12 -3
- package/skills/yad-docs/templates/app/src/hooks/usePlayback.ts +3 -3
- package/skills/yad-docs/templates/app/src/store/useFlowStore.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
# [2.
|
|
1
|
+
# [2.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.11.1...v2.12.0) (2026-06-16)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **checks:** harden spec-link + gitlab gate templates ([#69](https://github.com/abdelrahmannasr/yadflow/issues/69)) ([42f3949](https://github.com/abdelrahmannasr/yadflow/commit/42f3949841095624db03cb17f85db3138be8b93b))
|
|
2
7
|
|
|
3
8
|
|
|
4
9
|
### Features
|
|
5
10
|
|
|
6
|
-
*
|
|
11
|
+
* **docs:** pipeline-shaped overview canvas + collapsible panels + content refresh ([#70](https://github.com/abdelrahmannasr/yadflow/issues/70)) ([da47b80](https://github.com/abdelrahmannasr/yadflow/commit/da47b8050ccd7aa1919bf4b364298a90cdd56012))
|
|
7
12
|
|
|
8
13
|
# [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
|
|
9
14
|
|
package/cli/doctor.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSO
|
|
|
8
8
|
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
|
|
9
9
|
import { loadLedger, epicRoot } from './epic-state.mjs';
|
|
10
10
|
import { gitHead } from './setup.mjs';
|
|
11
|
-
import { cliFor, validateLogin } from './platform.mjs';
|
|
11
|
+
import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
|
|
12
12
|
|
|
13
13
|
const MIN_NODE = 18;
|
|
14
14
|
|
|
@@ -68,8 +68,13 @@ export function projectChecks(checks, root) {
|
|
|
68
68
|
// platform CLI + auth (best-effort; auth probing is the user's own session)
|
|
69
69
|
const cli = cliFor(hub.platform);
|
|
70
70
|
if (cli) {
|
|
71
|
+
// Scope the auth probe to the hub's own host (derived from git_url). `${cli} auth status`
|
|
72
|
+
// without --hostname exits non-zero when ANY configured instance fails, so an unrelated
|
|
73
|
+
// stale login (e.g. a dead gitlab.com token) would falsely flag a working self-hosted hub.
|
|
74
|
+
const host = hostFromGitUrl(hub.git_url);
|
|
75
|
+
const authArgs = host ? ['auth', 'status', '--hostname', host] : ['auth', 'status'];
|
|
71
76
|
if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to file-only without it`);
|
|
72
|
-
else if (!run(cli,
|
|
77
|
+
else if (!run(cli, authArgs).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated${host ? ` for ${host}` : ''} [YAD-ENV-002]`, `run \`${cli} auth login${host ? ` --hostname ${host}` : ''}\``);
|
|
73
78
|
else {
|
|
74
79
|
check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
|
|
75
80
|
// Re-validate each roster login against the hub (warn-only). Skips when a login is already
|
package/cli/platform.mjs
CHANGED
|
@@ -17,6 +17,23 @@ export function cliFor(platform) {
|
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// Bare host from a git remote URL, for hostname-scoped CLI auth checks. Handles both the
|
|
21
|
+
// `https://[user@]host[:port]/...` and the scp-like `git@host:group/repo.git` forms. Returns
|
|
22
|
+
// null when nothing parses (caller falls back to an unscoped check).
|
|
23
|
+
export function hostFromGitUrl(url = '') {
|
|
24
|
+
if (typeof url !== 'string' || !url.trim()) return null;
|
|
25
|
+
const u = url.trim();
|
|
26
|
+
// scp-like syntax: [user@]host:path — only when there's no scheme and the colon precedes a path.
|
|
27
|
+
const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/);
|
|
28
|
+
if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) return scp[1].toLowerCase() || null;
|
|
29
|
+
try {
|
|
30
|
+
// URL needs a scheme to parse a host; ssh:// and https:// both work here.
|
|
31
|
+
return new URL(u).hostname.toLowerCase() || null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
// Is the platform CLI present? (auth is the user's own; we don't probe it here.)
|
|
21
38
|
export function platformReady(platform) {
|
|
22
39
|
const cli = cliFor(platform);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo). A BMAD module + 30 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -18,10 +18,17 @@ repo uses. Each reads conventions established by earlier steps — it invents no
|
|
|
18
18
|
|
|
19
19
|
## 1. spec-link (`templates/checks/spec-link.sh`)
|
|
20
20
|
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
`
|
|
21
|
+
- Checks every non-merge commit in `<base>..HEAD` **per commit** (not aggregated across the range),
|
|
22
|
+
so the report names each offending commit and one bad commit never masks the rest.
|
|
23
|
+
- Maintenance commits are **exempt**: a Conventional-Commits subject of type `ci`, `chore`, `build`,
|
|
24
|
+
or `test` (optional `(scope)` / breaking `!`) **PASSes** without a link — CI wiring, dependency
|
|
25
|
+
bumps, and test-infra changes legitimately link no story.
|
|
26
|
+
- For every other commit, requires a `Task: <story>-<task>` trailer. **FAIL** if absent.
|
|
27
|
+
- The trailer must be a well-formed `<story>-T<NN>` id. **FAIL** on a malformed trailer (e.g.
|
|
28
|
+
`EP-demo-S01` with no `-T<NN>`) rather than letting it slip through the suffix-strip.
|
|
29
|
+
- Strips the `-T<NN>` suffix from the task to get `<story>` and requires `specs/<story>/link.md` to
|
|
30
|
+
exist. **FAIL** if missing.
|
|
31
|
+
- An empty range (no non-merge commits) **PASSes**.
|
|
25
32
|
- Portable across bash 3.2 (macOS) and 4+ (no `mapfile`).
|
|
26
33
|
- **Fails closed** when `<base>` can't be resolved (so a shallow clone / wrong base never PASSes blind).
|
|
27
34
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# spec-link gate (Phase 3 build plan §C).
|
|
3
|
-
#
|
|
3
|
+
# Every NON-MAINTENANCE commit must link a real story/spec: it must carry a
|
|
4
4
|
# `Task: <story>-<task>` trailer whose <story> resolves to a specs/<story>/link.md.
|
|
5
|
-
#
|
|
5
|
+
# Maintenance commits (ci/chore/build/test) are EXEMPT — CI wiring, dependency bumps,
|
|
6
|
+
# and test-infra changes legitimately link no story. Checked per commit (not aggregated
|
|
7
|
+
# across the range), so the report names every offending commit.
|
|
6
8
|
set -euo pipefail
|
|
7
9
|
|
|
8
10
|
BASE="${1:-${SDLC_BASE:-origin/main}}"
|
|
@@ -14,25 +16,48 @@ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
|
|
|
14
16
|
fi
|
|
15
17
|
RANGE="${BASE}..HEAD"
|
|
16
18
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
+
# Conventional-Commits types exempt from the spec-link requirement (optional (scope) and breaking !).
|
|
20
|
+
EXEMPT='ci|chore|build|test'
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
# Portable across bash 3.2 (macOS) and 4+ — no mapfile; feed the loop via heredoc (not a pipe) so
|
|
23
|
+
# the failure count survives the loop body.
|
|
24
|
+
commits="$(git rev-list --no-merges "$RANGE")"
|
|
25
|
+
if [ -z "$commits" ]; then
|
|
26
|
+
echo "PASS [spec-link]: no non-merge commits in ${RANGE}"
|
|
27
|
+
exit 0
|
|
23
28
|
fi
|
|
24
29
|
|
|
25
30
|
rc=0
|
|
26
|
-
while IFS= read -r
|
|
27
|
-
[ -z "$
|
|
28
|
-
|
|
31
|
+
while IFS= read -r sha; do
|
|
32
|
+
[ -z "$sha" ] && continue
|
|
33
|
+
short="$(git log -1 --format=%h "$sha")"
|
|
34
|
+
subject="$(git log -1 --format=%s "$sha")"
|
|
35
|
+
if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: "; then
|
|
36
|
+
echo "PASS [spec-link]: ${short} '${subject}' — maintenance commit (exempt)"
|
|
37
|
+
continue
|
|
38
|
+
fi
|
|
39
|
+
task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
|
|
40
|
+
if [ -z "$task" ]; then
|
|
41
|
+
echo "FAIL [spec-link]: ${short} '${subject}' has no 'Task:' trailer"
|
|
42
|
+
rc=1
|
|
43
|
+
continue
|
|
44
|
+
fi
|
|
45
|
+
# The trailer must be a real <story>-T<NN> id. Without this guard a malformed trailer
|
|
46
|
+
# (e.g. 'EP-demo-S01' with no -T<NN>) would survive the suffix-strip unchanged and PASS
|
|
47
|
+
# whenever specs/<that>/link.md happens to exist.
|
|
48
|
+
if ! printf '%s' "$task" | grep -qE '.+-T[0-9]+$'; then
|
|
49
|
+
echo "FAIL [spec-link]: ${short} '${subject}' has a malformed Task trailer '${task}' (expected <story>-T<NN>)."
|
|
50
|
+
rc=1
|
|
51
|
+
continue
|
|
52
|
+
fi
|
|
53
|
+
story="$(printf '%s' "$task" | sed -E 's/-T[0-9]+$//')"
|
|
29
54
|
if [ -f "specs/${story}/link.md" ]; then
|
|
30
|
-
echo "PASS [spec-link]: ${
|
|
55
|
+
echo "PASS [spec-link]: ${short} ${task} -> specs/${story}/link.md"
|
|
31
56
|
else
|
|
32
|
-
echo "FAIL [spec-link]: ${
|
|
57
|
+
echo "FAIL [spec-link]: ${short} ${task} references specs/${story}/ but link.md is missing."
|
|
33
58
|
rc=1
|
|
34
59
|
fi
|
|
35
60
|
done <<EOF
|
|
36
|
-
$
|
|
61
|
+
$commits
|
|
37
62
|
EOF
|
|
38
63
|
exit "$rc"
|
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
# pipeline's default stage regardless of any `stages:` order the foreign root defines — a foreign
|
|
10
10
|
# `stages:` list can neither break them nor reorder them. Job names are yad-prefixed to avoid
|
|
11
11
|
# colliding with the host project's own job names.
|
|
12
|
-
|
|
12
|
+
#
|
|
13
|
+
# `image:`/`tags:` live on the .sdlc_mr_only anchor below — NOT a top-level `default:`. An included
|
|
14
|
+
# top-level `default:` merges into the HOST root pipeline; if the host already sets a top-level
|
|
15
|
+
# `image:`, GitLab rejects the whole config ("image is defined in top-level and 'default:' entry").
|
|
16
|
+
# Putting them on the anchor scopes them to the gate jobs (which all `extends: .sdlc_mr_only`) and
|
|
17
|
+
# never touches the host root.
|
|
18
|
+
|
|
19
|
+
.sdlc_mr_only:
|
|
13
20
|
image: node:20
|
|
14
21
|
# Runner selection. These jobs need a docker/dind executor; on instances whose runners are all
|
|
15
22
|
# tag-locked (run_untagged: false) an untagged image job never starts. Set the YAD_RUNNER_TAGS
|
|
@@ -19,8 +26,6 @@ default:
|
|
|
19
26
|
# instead strand it `pending` — if so, set the variable to a real tag. Single value only:
|
|
20
27
|
# `tags: [$VAR]` is one tag equal to the whole variable, not a comma-split.
|
|
21
28
|
tags: [$YAD_RUNNER_TAGS]
|
|
22
|
-
|
|
23
|
-
.sdlc_mr_only:
|
|
24
29
|
rules:
|
|
25
30
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
26
31
|
|
|
@@ -45,6 +50,7 @@ yad-build-test-lint:
|
|
|
45
50
|
variables:
|
|
46
51
|
YAD_TEST_MAX_WORKERS: "2" # cap jest/vitest test workers in CI; ignored by other runners
|
|
47
52
|
script:
|
|
53
|
+
- npm ci
|
|
48
54
|
- bash checks/build-test-lint.sh
|
|
49
55
|
|
|
50
56
|
# Pattern gates: commit subject + MR title + MR body all follow the convention (profile: code).
|
|
@@ -14,34 +14,69 @@ import { RoleSelectPage } from './pages/RoleSelectPage';
|
|
|
14
14
|
import { StakeholderDocPage } from './pages/StakeholderDocPage';
|
|
15
15
|
import { usePlayback } from './hooks/usePlayback';
|
|
16
16
|
import { useFlowStore } from './store/useFlowStore';
|
|
17
|
+
import { Icon } from './components/shared/Icon';
|
|
18
|
+
|
|
19
|
+
const railStyle = {
|
|
20
|
+
borderColor: 'var(--color-border-default)',
|
|
21
|
+
background: 'var(--color-bg-primary)',
|
|
22
|
+
};
|
|
17
23
|
|
|
18
24
|
function Dashboard() {
|
|
19
25
|
usePlayback();
|
|
20
26
|
const isLogsPanelOpen = useFlowStore((s) => s.isLogsPanelOpen);
|
|
27
|
+
const isLeftPanelOpen = useFlowStore((s) => s.isLeftPanelOpen);
|
|
28
|
+
const isRightPanelOpen = useFlowStore((s) => s.isRightPanelOpen);
|
|
29
|
+
const toggleLeftPanel = useFlowStore((s) => s.toggleLeftPanel);
|
|
30
|
+
const toggleRightPanel = useFlowStore((s) => s.toggleRightPanel);
|
|
21
31
|
|
|
22
32
|
return (
|
|
23
33
|
<div className="flex flex-1 overflow-hidden">
|
|
24
|
-
{/* Left Sidebar */}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
{/* Left Sidebar (collapsible — collapse to widen the canvas) */}
|
|
35
|
+
{isLeftPanelOpen ? (
|
|
36
|
+
<aside
|
|
37
|
+
className="w-80 flex-none flex flex-col border-r z-10"
|
|
38
|
+
style={railStyle}
|
|
39
|
+
>
|
|
40
|
+
{/* Collapse header */}
|
|
41
|
+
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
|
42
|
+
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
|
|
43
|
+
Navigator
|
|
44
|
+
</span>
|
|
45
|
+
<button
|
|
46
|
+
onClick={toggleLeftPanel}
|
|
47
|
+
title="Collapse panel"
|
|
48
|
+
aria-label="Collapse navigator panel"
|
|
49
|
+
className="flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
|
|
50
|
+
>
|
|
51
|
+
<Icon name="left_panel_close" size={18} />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
36
54
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
{/* Path Selection */}
|
|
56
|
+
<div className="p-4 border-b overflow-y-auto" style={{ borderColor: 'var(--color-border-default)' }}>
|
|
57
|
+
<PathSelector />
|
|
58
|
+
</div>
|
|
41
59
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
{/* Step Timeline */}
|
|
61
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
62
|
+
<StepList />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{/* Footer */}
|
|
66
|
+
<SidebarFooter />
|
|
67
|
+
</aside>
|
|
68
|
+
) : (
|
|
69
|
+
<div className="w-10 flex-none flex flex-col items-center border-r z-10 pt-3" style={railStyle}>
|
|
70
|
+
<button
|
|
71
|
+
onClick={toggleLeftPanel}
|
|
72
|
+
title="Expand navigator"
|
|
73
|
+
aria-label="Expand navigator panel"
|
|
74
|
+
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
|
|
75
|
+
>
|
|
76
|
+
<Icon name="left_panel_open" size={18} />
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
45
80
|
|
|
46
81
|
{/* Main Canvas Area */}
|
|
47
82
|
<main className="flex-1 flex flex-col overflow-hidden relative flow-grid"
|
|
@@ -65,8 +100,21 @@ function Dashboard() {
|
|
|
65
100
|
<PlaybackBar />
|
|
66
101
|
</main>
|
|
67
102
|
|
|
68
|
-
{/* Right Detail Panel */}
|
|
69
|
-
|
|
103
|
+
{/* Right Detail Panel (collapsible — collapse to widen the canvas) */}
|
|
104
|
+
{isRightPanelOpen ? (
|
|
105
|
+
<RightPanel />
|
|
106
|
+
) : (
|
|
107
|
+
<div className="w-10 flex-none flex flex-col items-center border-l z-10 pt-3" style={railStyle}>
|
|
108
|
+
<button
|
|
109
|
+
onClick={toggleRightPanel}
|
|
110
|
+
title="Expand details"
|
|
111
|
+
aria-label="Expand step-details panel"
|
|
112
|
+
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
|
|
113
|
+
>
|
|
114
|
+
<Icon name="right_panel_open" size={18} />
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
70
118
|
</div>
|
|
71
119
|
);
|
|
72
120
|
}
|
|
@@ -75,6 +75,26 @@ export const FlowCanvas: React.FC = () => {
|
|
|
75
75
|
|
|
76
76
|
const messages = currentStep?.messages || [];
|
|
77
77
|
|
|
78
|
+
// Optional loop-back arc, swept below the canvas to read as "the pipeline repeats".
|
|
79
|
+
// Rendered only when both endpoint nodes exist (e.g. the SDLC-overview's
|
|
80
|
+
// trust-log → product-hub cycle); a no-op for diagrams without them.
|
|
81
|
+
const loopBack = useMemo(() => {
|
|
82
|
+
if (dims.width === 0) return null;
|
|
83
|
+
const a = COMPONENTS.find((c) => c.id === 'trust-log');
|
|
84
|
+
const b = COMPONENTS.find((c) => c.id === 'product-hub');
|
|
85
|
+
if (!a || !b) return null;
|
|
86
|
+
const x1 = (a.position.x / 100) * dims.width;
|
|
87
|
+
const y1 = (a.position.y / 100) * dims.height;
|
|
88
|
+
const x2 = (b.position.x / 100) * dims.width;
|
|
89
|
+
const y2 = (b.position.y / 100) * dims.height;
|
|
90
|
+
const yb = dims.height * 0.985;
|
|
91
|
+
return {
|
|
92
|
+
d: `M ${x1} ${y1} C ${x1} ${yb}, ${x2} ${yb}, ${x2} ${y2}`,
|
|
93
|
+
labelX: (x1 + x2) / 2,
|
|
94
|
+
labelY: dims.height * 0.92,
|
|
95
|
+
};
|
|
96
|
+
}, [dims]);
|
|
97
|
+
|
|
78
98
|
return (
|
|
79
99
|
<div ref={containerRef} className="relative h-full w-full overflow-hidden">
|
|
80
100
|
{/* Canvas Header Overlay */}
|
|
@@ -159,6 +179,33 @@ export const FlowCanvas: React.FC = () => {
|
|
|
159
179
|
containerHeight={dims.height}
|
|
160
180
|
/>
|
|
161
181
|
))}
|
|
182
|
+
|
|
183
|
+
{/* Loop-back: the pipeline repeats per epic */}
|
|
184
|
+
{loopBack && (
|
|
185
|
+
<g>
|
|
186
|
+
<path
|
|
187
|
+
d={loopBack.d}
|
|
188
|
+
fill="none"
|
|
189
|
+
stroke="#ff6490"
|
|
190
|
+
strokeWidth={1.5}
|
|
191
|
+
strokeDasharray="6 5"
|
|
192
|
+
opacity={0.55}
|
|
193
|
+
markerEnd="url(#arrowhead)"
|
|
194
|
+
/>
|
|
195
|
+
<text
|
|
196
|
+
x={loopBack.labelX}
|
|
197
|
+
y={loopBack.labelY}
|
|
198
|
+
textAnchor="middle"
|
|
199
|
+
fontSize={12}
|
|
200
|
+
fontWeight={700}
|
|
201
|
+
letterSpacing={0.6}
|
|
202
|
+
fill="#ff8db0"
|
|
203
|
+
opacity={0.85}
|
|
204
|
+
>
|
|
205
|
+
↺ pipeline repeats per epic
|
|
206
|
+
</text>
|
|
207
|
+
</g>
|
|
208
|
+
)}
|
|
162
209
|
</svg>
|
|
163
210
|
)}
|
|
164
211
|
|
|
@@ -12,6 +12,7 @@ export function RightPanel() {
|
|
|
12
12
|
const getCurrentStep = useFlowStore((s) => s.getCurrentStep);
|
|
13
13
|
const activeStepIndex = useFlowStore((s) => s.activeStepIndex);
|
|
14
14
|
const selectedPath = useFlowStore((s) => s.selectedPath);
|
|
15
|
+
const toggleRightPanel = useFlowStore((s) => s.toggleRightPanel);
|
|
15
16
|
const navigate = useNavigate();
|
|
16
17
|
const step = getCurrentStep();
|
|
17
18
|
|
|
@@ -29,7 +30,14 @@ export function RightPanel() {
|
|
|
29
30
|
<h3 className="text-slate-100 text-sm font-bold font-display uppercase tracking-wider">
|
|
30
31
|
Step Details
|
|
31
32
|
</h3>
|
|
32
|
-
<
|
|
33
|
+
<button
|
|
34
|
+
onClick={toggleRightPanel}
|
|
35
|
+
title="Collapse panel"
|
|
36
|
+
aria-label="Collapse step-details panel"
|
|
37
|
+
className="flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
|
|
38
|
+
>
|
|
39
|
+
<Icon name="right_panel_close" size={18} />
|
|
40
|
+
</button>
|
|
33
41
|
</div>
|
|
34
42
|
|
|
35
43
|
{!step ? (
|
|
@@ -43,15 +43,6 @@ export const StepList = () => {
|
|
|
43
43
|
}
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
// Reset expanded state when active step changes externally (playback)
|
|
47
|
-
const prevActiveRef = { current: activeStepIndex };
|
|
48
|
-
if (prevActiveRef.current !== activeStepIndex) {
|
|
49
|
-
prevActiveRef.current = activeStepIndex;
|
|
50
|
-
if (expandedIndex !== null && expandedIndex !== activeStepIndex) {
|
|
51
|
-
// Auto-expand the new active step during playback
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
46
|
return (
|
|
56
47
|
<div className="flex flex-col gap-2">
|
|
57
48
|
<div className="flex items-center justify-between mb-2">
|
|
@@ -28,13 +28,16 @@ export function CommandPalette() {
|
|
|
28
28
|
return () => window.removeEventListener('keydown', handler);
|
|
29
29
|
}, [isOpen, toggle]);
|
|
30
30
|
|
|
31
|
-
// Reset on open
|
|
32
|
-
|
|
31
|
+
// Reset on open — adjust state during render (not inside an effect) so it does
|
|
32
|
+
// not trigger an extra cascading render (react-hooks/set-state-in-effect).
|
|
33
|
+
const [wasOpen, setWasOpen] = useState(isOpen);
|
|
34
|
+
if (isOpen !== wasOpen) {
|
|
35
|
+
setWasOpen(isOpen);
|
|
33
36
|
if (isOpen) {
|
|
34
37
|
setQuery('');
|
|
35
38
|
setSelectedIndex(0);
|
|
36
39
|
}
|
|
37
|
-
}
|
|
40
|
+
}
|
|
38
41
|
|
|
39
42
|
const results = useMemo(() => {
|
|
40
43
|
if (!query.trim()) {
|
|
@@ -9,13 +9,22 @@ export function useAnimationQueue() {
|
|
|
9
9
|
const [animKey, setAnimKey] = useState(0);
|
|
10
10
|
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
11
11
|
|
|
12
|
-
//
|
|
12
|
+
// Reset the pulse state the instant the step (or speed) changes — done during
|
|
13
|
+
// render via the "adjust state on change" pattern rather than inside the effect,
|
|
14
|
+
// so it does not trigger an extra cascading render (react-hooks/set-state-in-effect).
|
|
15
|
+
const stepKey = `${activeStepIndex}-${speed}`;
|
|
16
|
+
const [resetKey, setResetKey] = useState(stepKey);
|
|
17
|
+
if (stepKey !== resetKey) {
|
|
18
|
+
setResetKey(stepKey);
|
|
19
|
+
setCompletedTargets(new Set());
|
|
20
|
+
setAnimKey((k) => k + 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// When step changes, schedule the target-pulse completion events.
|
|
13
24
|
useEffect(() => {
|
|
14
25
|
// Clear previous timers
|
|
15
26
|
timersRef.current.forEach(clearTimeout);
|
|
16
27
|
timersRef.current = [];
|
|
17
|
-
setCompletedTargets(new Set());
|
|
18
|
-
setAnimKey((k) => k + 1);
|
|
19
28
|
|
|
20
29
|
// Get messages from the current step
|
|
21
30
|
const step = useFlowStore.getState().getCurrentStep();
|
|
@@ -30,7 +30,7 @@ export function usePlayback() {
|
|
|
30
30
|
return () => {
|
|
31
31
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
32
32
|
};
|
|
33
|
-
}, [playbackState]);
|
|
33
|
+
}, [playbackState, tickElapsed, resetElapsed]);
|
|
34
34
|
|
|
35
35
|
// Generate logs on step change
|
|
36
36
|
useEffect(() => {
|
|
@@ -56,7 +56,7 @@ export function usePlayback() {
|
|
|
56
56
|
});
|
|
57
57
|
}, msg.delay / speed);
|
|
58
58
|
}
|
|
59
|
-
}, [activeStepIndex, speed]);
|
|
59
|
+
}, [activeStepIndex, speed, addLog, getCurrentSteps]);
|
|
60
60
|
|
|
61
61
|
useEffect(() => {
|
|
62
62
|
if (playbackState !== "playing") {
|
|
@@ -96,5 +96,5 @@ export function usePlayback() {
|
|
|
96
96
|
return () => {
|
|
97
97
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
98
98
|
};
|
|
99
|
-
}, [playbackState, activeStepIndex, speed, animatingMessages]);
|
|
99
|
+
}, [playbackState, activeStepIndex, speed, animatingMessages, getCurrentSteps, nextStep, setAnimatingMessages]);
|
|
100
100
|
}
|
|
@@ -26,6 +26,8 @@ interface FlowStore {
|
|
|
26
26
|
isReferencePanelOpen: boolean;
|
|
27
27
|
isCommandPaletteOpen: boolean;
|
|
28
28
|
isLogsPanelOpen: boolean;
|
|
29
|
+
isLeftPanelOpen: boolean;
|
|
30
|
+
isRightPanelOpen: boolean;
|
|
29
31
|
|
|
30
32
|
// Logs
|
|
31
33
|
logs: LogEntry[];
|
|
@@ -57,6 +59,8 @@ interface FlowStore {
|
|
|
57
59
|
toggleReferencePanel: () => void;
|
|
58
60
|
toggleCommandPalette: () => void;
|
|
59
61
|
toggleLogsPanel: () => void;
|
|
62
|
+
toggleLeftPanel: () => void;
|
|
63
|
+
toggleRightPanel: () => void;
|
|
60
64
|
|
|
61
65
|
// Logs
|
|
62
66
|
addLog: (entry: Omit<LogEntry, 'id' | 'timestamp'>) => void;
|
|
@@ -90,6 +94,8 @@ export const useFlowStore = create<FlowStore>((set, get) => ({
|
|
|
90
94
|
isReferencePanelOpen: false,
|
|
91
95
|
isCommandPaletteOpen: false,
|
|
92
96
|
isLogsPanelOpen: false,
|
|
97
|
+
isLeftPanelOpen: true,
|
|
98
|
+
isRightPanelOpen: true,
|
|
93
99
|
logs: [],
|
|
94
100
|
elapsedTime: 0,
|
|
95
101
|
|
|
@@ -157,6 +163,8 @@ export const useFlowStore = create<FlowStore>((set, get) => ({
|
|
|
157
163
|
toggleReferencePanel: () => set((s) => ({ isReferencePanelOpen: !s.isReferencePanelOpen })),
|
|
158
164
|
toggleCommandPalette: () => set((s) => ({ isCommandPaletteOpen: !s.isCommandPaletteOpen })),
|
|
159
165
|
toggleLogsPanel: () => set((s) => ({ isLogsPanelOpen: !s.isLogsPanelOpen })),
|
|
166
|
+
toggleLeftPanel: () => set((s) => ({ isLeftPanelOpen: !s.isLeftPanelOpen })),
|
|
167
|
+
toggleRightPanel: () => set((s) => ({ isRightPanelOpen: !s.isRightPanelOpen })),
|
|
160
168
|
|
|
161
169
|
// Logs
|
|
162
170
|
addLog: (entry) => {
|