@zenuml/core 3.47.8 → 3.48.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.
Files changed (204) hide show
  1. package/dist/cloud-icons-eHuugVSv.js.map +1 -0
  2. package/dist/zenuml.esm.mjs +2153 -2156
  3. package/dist/zenuml.esm.mjs.map +1 -0
  4. package/dist/zenuml.js +82 -82
  5. package/dist/zenuml.js.map +1 -0
  6. package/package.json +11 -1
  7. package/src/cli/zenuml.ts +1164 -0
  8. package/.agents/skills/babysit-pr/SKILL.md +0 -223
  9. package/.agents/skills/babysit-pr/agents/openai.yaml +0 -7
  10. package/.agents/skills/dia-scoring/SKILL.md +0 -139
  11. package/.agents/skills/dia-scoring/agents/openai.yaml +0 -7
  12. package/.agents/skills/dia-scoring/references/selectors-and-keys.md +0 -253
  13. package/.agents/skills/land-pr/SKILL.md +0 -120
  14. package/.agents/skills/propagate-core-release/SKILL.md +0 -205
  15. package/.agents/skills/propagate-core-release/agents/openai.yaml +0 -7
  16. package/.agents/skills/propagate-core-release/references/downstreams.md +0 -42
  17. package/.agents/skills/ship-branch/SKILL.md +0 -105
  18. package/.agents/skills/submit-branch/SKILL.md +0 -76
  19. package/.agents/skills/validate-branch/SKILL.md +0 -72
  20. package/.claude/commands/README.md +0 -162
  21. package/.claude/commands/analyze.md +0 -101
  22. package/.claude/commands/clarify.md +0 -158
  23. package/.claude/commands/code-review.md +0 -322
  24. package/.claude/commands/constitution.md +0 -73
  25. package/.claude/commands/create-docs.md +0 -309
  26. package/.claude/commands/full-context.md +0 -121
  27. package/.claude/commands/gemini-consult.md +0 -164
  28. package/.claude/commands/handoff.md +0 -146
  29. package/.claude/commands/implement.md +0 -56
  30. package/.claude/commands/plan.md +0 -43
  31. package/.claude/commands/refactor.md +0 -188
  32. package/.claude/commands/specify.md +0 -21
  33. package/.claude/commands/tasks.md +0 -62
  34. package/.claude/commands/update-docs.md +0 -314
  35. package/.claude/hooks/README.md +0 -270
  36. package/.claude/hooks/config/sensitive-patterns.json +0 -86
  37. package/.claude/hooks/gemini-context-injector.sh +0 -129
  38. package/.claude/hooks/mcp-security-scan.sh +0 -147
  39. package/.claude/hooks/notify.sh +0 -103
  40. package/.claude/hooks/setup/hook-setup.md +0 -96
  41. package/.claude/hooks/setup/settings.json.template +0 -63
  42. package/.claude/hooks/sounds/complete.wav +0 -0
  43. package/.claude/hooks/sounds/input-needed.wav +0 -0
  44. package/.claude/hooks/subagent-context-injector.sh +0 -65
  45. package/.claude/skills/babysit-pr/SKILL.md +0 -223
  46. package/.claude/skills/babysit-pr/agents/openai.yaml +0 -7
  47. package/.claude/skills/dia-scoring/SKILL.md +0 -139
  48. package/.claude/skills/dia-scoring/agents/openai.yaml +0 -7
  49. package/.claude/skills/dia-scoring/references/selectors-and-keys.md +0 -253
  50. package/.claude/skills/emoji-eval/SKILL.md +0 -187
  51. package/.claude/skills/land-pr/SKILL.md +0 -120
  52. package/.claude/skills/propagate-core-release/SKILL.md +0 -205
  53. package/.claude/skills/propagate-core-release/agents/openai.yaml +0 -7
  54. package/.claude/skills/propagate-core-release/references/downstreams.md +0 -42
  55. package/.claude/skills/ship-branch/SKILL.md +0 -105
  56. package/.claude/skills/submit-branch/SKILL.md +0 -76
  57. package/.claude/skills/validate-branch/SKILL.md +0 -72
  58. package/.claude/skills/zenuml-ux-research/SKILL.md +0 -183
  59. package/.claude/skills/zenuml-ux-research/references/assertion-catalog.md +0 -261
  60. package/.claude/skills/zenuml-ux-research/references/best-practices-overview.md +0 -56
  61. package/.claude/skills/zenuml-ux-research/references/report-template.md +0 -89
  62. package/.claude/skills/zenuml-ux-research/references/scenarios/edit-message-label.md +0 -37
  63. package/.claude/skills/zenuml-ux-research/references/scenarios/insert-message.md +0 -36
  64. package/.claude/skills/zenuml-ux-research/references/scenarios/insert-participant.md +0 -31
  65. package/.claude/skills/zenuml-ux-research/references/scenarios/rename-participant.md +0 -33
  66. package/.claude/skills/zenuml-ux-research/references/scenarios/undo-insert.md +0 -35
  67. package/.devcontainer/devcontainer.json +0 -21
  68. package/.dockerignore +0 -19
  69. package/.eslintrc.js +0 -39
  70. package/.git-blame-ignore-revs +0 -6
  71. package/.kiro/hooks/README.md +0 -38
  72. package/.kiro/hooks/session-sound-notification.js +0 -44
  73. package/.kiro/hooks/session-sound-notification.json +0 -23
  74. package/.mcp.json.example +0 -17
  75. package/.nvmrc +0 -1
  76. package/.prettierignore +0 -4
  77. package/.prettierrc +0 -1
  78. package/.specify/memory/constitution.md +0 -33
  79. package/.specify/scripts/bash/check-prerequisites.sh +0 -166
  80. package/.specify/scripts/bash/common.sh +0 -113
  81. package/.specify/scripts/bash/create-new-feature.sh +0 -97
  82. package/.specify/scripts/bash/setup-plan.sh +0 -60
  83. package/.specify/scripts/bash/update-agent-context.sh +0 -728
  84. package/.specify/templates/agent-file-template.md +0 -23
  85. package/.specify/templates/plan-template.md +0 -219
  86. package/.specify/templates/spec-template.md +0 -116
  87. package/.specify/templates/tasks-template.md +0 -127
  88. package/.storybook/main.ts +0 -25
  89. package/.storybook/preview.ts +0 -29
  90. package/.watchmanconfig +0 -3
  91. package/AGENTS.md +0 -26
  92. package/CLAUDE.md +0 -124
  93. package/DEPLOYMENT.md +0 -62
  94. package/Dockerfile +0 -36
  95. package/IMPLEMENTATION_PLAN.md +0 -163
  96. package/Integration/vanilla-js/index.html +0 -42
  97. package/MCP-ASSISTANT-RULES.md +0 -85
  98. package/README_CN.md +0 -15
  99. package/TUTORIAL.md +0 -116
  100. package/antlr/antlr-4.11.1-complete.jar +0 -0
  101. package/bun.lock +0 -1544
  102. package/bunfig.toml +0 -52
  103. package/docs/UNICODE_SUPPORT.md +0 -179
  104. package/docs/ai-context/deployment-infrastructure.md +0 -21
  105. package/docs/ai-context/docs-overview.md +0 -89
  106. package/docs/ai-context/handoff.md +0 -174
  107. package/docs/ai-context/project-structure.md +0 -160
  108. package/docs/ai-context/system-integration.md +0 -21
  109. package/docs/asciidoc/contributor.adoc +0 -54
  110. package/docs/asciidoc/create-my-own-theme.adoc +0 -149
  111. package/docs/asciidoc/images/creation-component.png +0 -0
  112. package/docs/asciidoc/images/creation-rtl.png +0 -0
  113. package/docs/asciidoc/images/message-arrow-rtl.png +0 -0
  114. package/docs/asciidoc/images/occurrence.png +0 -0
  115. package/docs/asciidoc/images/return-message-conflict.png +0 -0
  116. package/docs/asciidoc/images/shift-up-half-the-height.png +0 -0
  117. package/docs/asciidoc/images/three-layer-info-arch.png +0 -0
  118. package/docs/asciidoc/images/vertical-alignment.svg +0 -1
  119. package/docs/asciidoc/images/vertically-aligning.png +0 -0
  120. package/docs/asciidoc/index.adoc +0 -277
  121. package/docs/asciidoc/theme-debug-web-app.png +0 -0
  122. package/docs/asciidoc/tutorial.adoc +0 -22
  123. package/docs/asciidoc/user-css.png +0 -0
  124. package/docs/async-vs-sync-parser-rules.md +0 -81
  125. package/docs/divider-parser-allow-spaces.md +0 -38
  126. package/docs/highlighting-messages.md +0 -52
  127. package/docs/images/editor-sample.png +0 -0
  128. package/docs/inherited-vs-provided-from.md +0 -64
  129. package/docs/parser/Assignment.md +0 -8
  130. package/docs/parser/PARSER_IMPROVEMENTS_CC.md +0 -425
  131. package/docs/parser/grammar_review_gemini.md +0 -116
  132. package/docs/participants-function.md +0 -25
  133. package/docs/responsive-participant-margin.md +0 -52
  134. package/docs/starter.md +0 -9
  135. package/docs/superpowers/plans/2026-03-27-e2e-test-reorg.md +0 -698
  136. package/docs/superpowers/plans/2026-03-30-emoji-support.md +0 -1220
  137. package/docs/superpowers/plans/2026-03-30-self-correcting-scoring.md +0 -206
  138. package/docs/superpowers/plans/2026-04-15-keyboard-editing-on-diagram.md +0 -1992
  139. package/docs/superpowers/plans/2026-04-15-zenuml-ux-research-skill.md +0 -1452
  140. package/docs/ux-research/.gitkeep +0 -0
  141. package/docs/ux-research/2026-04-15-rename-participant.md +0 -156
  142. package/docs/ux-research/2026-04-18-insert-participant.md +0 -151
  143. package/docs/width-translate-and-offsets.md +0 -62
  144. package/docs/xss.md +0 -59
  145. package/e2e/data/compare-cases.js +0 -1090
  146. package/e2e/data/diff-algorithm.js +0 -199
  147. package/e2e/fixtures/create-message.html +0 -26
  148. package/e2e/fixtures/editable-label.html +0 -35
  149. package/e2e/fixtures/editable-span.html +0 -122
  150. package/e2e/fixtures/empty-diagram.html +0 -23
  151. package/e2e/fixtures/fixture.html +0 -31
  152. package/e2e/fixtures/insert-participant.html +0 -23
  153. package/e2e/fixtures/reorder-cross-fragment.html +0 -31
  154. package/e2e/fixtures/reorder-fragment.html +0 -29
  155. package/e2e/fixtures/reorder-message.html +0 -27
  156. package/e2e/fixtures/svg-test.html +0 -21
  157. package/e2e/fixtures/type-switch.html +0 -29
  158. package/e2e/tools/canonical-history.html +0 -908
  159. package/e2e/tools/compare-case.html +0 -371
  160. package/e2e/tools/compare.html +0 -35
  161. package/e2e/tools/native-diff-ext/background.js +0 -60
  162. package/e2e/tools/native-diff-ext/bridge.js +0 -26
  163. package/e2e/tools/native-diff-ext/content.js +0 -194
  164. package/e2e/tools/svg-preview.html +0 -56
  165. package/embed.html +0 -193
  166. package/eslint.config.mjs +0 -35
  167. package/firebase-debug.log +0 -108
  168. package/iframe-container-demo/diagram.html +0 -124
  169. package/iframe-container-demo/host.html +0 -817
  170. package/index.html +0 -771
  171. package/mermaid-zenuml-async-spa-auth.png +0 -0
  172. package/mermaid-zenuml-async-spa-auth.snapshot.md +0 -96
  173. package/newsletter/unicode-support-announcement.md +0 -134
  174. package/playground/creation.html +0 -53
  175. package/playground/message.html +0 -63
  176. package/playwright.config.ts +0 -40
  177. package/renderer.html +0 -366
  178. package/scripts/analyze-compare-case/collect-data.mjs +0 -1134
  179. package/scripts/analyze-compare-case/config.mjs +0 -102
  180. package/scripts/analyze-compare-case/geometry.mjs +0 -101
  181. package/scripts/analyze-compare-case/native-diff.mjs +0 -224
  182. package/scripts/analyze-compare-case/output.mjs +0 -74
  183. package/scripts/analyze-compare-case/panel-diff.mjs +0 -114
  184. package/scripts/analyze-compare-case/report.mjs +0 -162
  185. package/scripts/analyze-compare-case/residual-scopes.mjs +0 -347
  186. package/scripts/analyze-compare-case/scoring.mjs +0 -829
  187. package/scripts/analyze-compare-case.mjs +0 -149
  188. package/scripts/bump-version.js +0 -117
  189. package/scripts/snapshot-dual.js +0 -173
  190. package/scripts/update-snapshots.js +0 -70
  191. package/skills/dia-scoring/SKILL.md +0 -129
  192. package/skills/dia-scoring/agents/openai.yaml +0 -7
  193. package/skills/dia-scoring/references/selectors-and-keys.md +0 -253
  194. package/tailwind.config.js +0 -126
  195. package/test-compression.html +0 -274
  196. package/test-mermaid-zenuml.html +0 -57
  197. package/test-setup.ts +0 -124
  198. package/test-url-params.html +0 -192
  199. package/tsconfig.app.json +0 -31
  200. package/tsconfig.node.json +0 -24
  201. package/tsconfig.test.json +0 -9
  202. package/vite.config.lib.ts +0 -93
  203. package/vite.config.ts +0 -84
  204. package/wrangler.toml +0 -18
@@ -1,1992 +0,0 @@
1
- # Keyboard-Only Editing on Diagram — Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Add a keyboard-only editing layer (arrow-key navigation + Miro-style Tab-to-sibling) on top of the existing mouse-driven edit-on-diagram feature. Scope: add participant, rename participant, add message, rename message.
6
-
7
- **Architecture:** Two independent focus rings (participants, messages) backed by Jotai atoms. Arrow keys move focus between elements via a top-level `useDiagramKeyboard` hook installed on `SeqDiagram`. Inside edit mode, `Tab` is intercepted by `EditableSpan` via a new `onTabCreateSibling` prop and routed to `insertParticipantIntoDsl` / `insertMessageInDsl`. Ring order is computed by pure functions that walk the ANTLR parse tree in DSL order.
8
-
9
- **Tech Stack:** React 19, Jotai, TypeScript, Vitest (unit), Playwright (E2E), Tailwind, Bun.
10
-
11
- **Spec:** `docs/superpowers/specs/2026-04-15-keyboard-editing-on-diagram-design.md`
12
-
13
- **Base branch:** `feat/keyboard-editing` (forked from `origin/feat/sequence-editor-interactions-v3`)
14
-
15
- ---
16
-
17
- ## File map
18
-
19
- ### New files
20
-
21
- - `src/store/keyboardAtoms.ts` — `focusedParticipantAtom`, `focusedMessageAtom`, paired setter atoms. Kept separate from `Store.ts` to avoid bloating it.
22
- - `src/components/DiagramFrame/SeqDiagram/keyboard/rings.ts` — pure ring builders. `buildParticipantRing(rootContext)`, `buildMessageRing(rootContext)`.
23
- - `src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts` — unit tests.
24
- - `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts` — top-level keydown handler.
25
- - `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx` — unit tests.
26
- - `src/components/DiagramFrame/SeqDiagram/keyboard/EmptyDiagramPlaceholder.tsx` — focusable phantom for empty diagrams.
27
- - `e2e/fixtures/keyboard-editing.html` — Playwright fixture page.
28
- - `tests/keyboard-editing.spec.ts` — Playwright spec covering the full flow.
29
-
30
- ### Modified files
31
-
32
- - `src/store/Store.ts` — re-export the keyboard atoms so consumers keep importing from `@/store/Store` (DRY).
33
- - `src/components/common/EditableSpan/EditableSpan.tsx` — add `onTabCreateSibling?: (direction: "before" | "after") => void` prop; intercept Tab in edit mode when provided.
34
- - `src/components/common/EditableSpan/EditableSpan.spec.tsx` — add test coverage for the new prop (create file if it does not exist).
35
- - `src/utils/participantInsertTransform.ts` — change `insertParticipantIntoDsl` return type from `string` to `{ code: string; labelPosition: [number, number] }` so we can auto-open the new participant in edit mode. Update call sites.
36
- - `src/utils/participantInsertTransform.spec.ts` — update tests for new return shape.
37
- - `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantInsertControls.tsx` — update to new return shape.
38
- - `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx` — wire `onTabCreateSibling`; render focus ring from `focusedParticipantAtom`.
39
- - `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx` — wire `onTabCreateSibling` with inherited from/to; render focus ring from `focusedMessageAtom`.
40
- - `src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx` — install `useDiagramKeyboard`; `tabIndex={0}` on container; auto-focus first participant on container focus; render `EmptyDiagramPlaceholder` when empty.
41
- - `src/components/DiagramFrame/SeqDiagram/SeqDiagram.css` — focus-ring styles.
42
-
43
- ---
44
-
45
- ## Task 1: Add focus atoms
46
-
47
- **Files:**
48
- - Create: `src/store/keyboardAtoms.ts`
49
- - Modify: `src/store/Store.ts`
50
- - Test: `src/store/keyboardAtoms.spec.ts`
51
-
52
- - [ ] **Step 1: Write the failing test**
53
-
54
- Create `src/store/keyboardAtoms.spec.ts`:
55
-
56
- ```typescript
57
- import { createStore } from "jotai";
58
- import {
59
- focusedParticipantAtom,
60
- focusedMessageAtom,
61
- setFocusedParticipantAtom,
62
- setFocusedMessageAtom,
63
- } from "./keyboardAtoms";
64
-
65
- describe("keyboardAtoms", () => {
66
- it("stores focused participant", () => {
67
- const store = createStore();
68
- store.set(setFocusedParticipantAtom, "A");
69
- expect(store.get(focusedParticipantAtom)).toBe("A");
70
- expect(store.get(focusedMessageAtom)).toBeNull();
71
- });
72
-
73
- it("setting focused message clears focused participant", () => {
74
- const store = createStore();
75
- store.set(setFocusedParticipantAtom, "A");
76
- store.set(setFocusedMessageAtom, { start: 10, end: 20 });
77
- expect(store.get(focusedParticipantAtom)).toBeNull();
78
- expect(store.get(focusedMessageAtom)).toEqual({ start: 10, end: 20 });
79
- });
80
-
81
- it("setting focused participant clears focused message", () => {
82
- const store = createStore();
83
- store.set(setFocusedMessageAtom, { start: 10, end: 20 });
84
- store.set(setFocusedParticipantAtom, "B");
85
- expect(store.get(focusedParticipantAtom)).toBe("B");
86
- expect(store.get(focusedMessageAtom)).toBeNull();
87
- });
88
-
89
- it("setting null on either clears that ring only", () => {
90
- const store = createStore();
91
- store.set(setFocusedParticipantAtom, "A");
92
- store.set(setFocusedParticipantAtom, null);
93
- expect(store.get(focusedParticipantAtom)).toBeNull();
94
- expect(store.get(focusedMessageAtom)).toBeNull();
95
- });
96
- });
97
- ```
98
-
99
- - [ ] **Step 2: Run test to verify it fails**
100
-
101
- Run: `bun run test src/store/keyboardAtoms.spec.ts`
102
- Expected: FAIL — module does not exist.
103
-
104
- - [ ] **Step 3: Implement the atoms**
105
-
106
- Create `src/store/keyboardAtoms.ts`:
107
-
108
- ```typescript
109
- import { atom } from "jotai";
110
-
111
- export type FocusedMessageKey = {
112
- start: number;
113
- end: number;
114
- };
115
-
116
- export const focusedParticipantAtom = atom<string | null>(null);
117
- export const focusedMessageAtom = atom<FocusedMessageKey | null>(null);
118
-
119
- export const setFocusedParticipantAtom = atom(
120
- null,
121
- (_get, set, name: string | null) => {
122
- set(focusedParticipantAtom, name);
123
- if (name !== null) {
124
- set(focusedMessageAtom, null);
125
- }
126
- },
127
- );
128
-
129
- export const setFocusedMessageAtom = atom(
130
- null,
131
- (_get, set, key: FocusedMessageKey | null) => {
132
- set(focusedMessageAtom, key);
133
- if (key !== null) {
134
- set(focusedParticipantAtom, null);
135
- }
136
- },
137
- );
138
- ```
139
-
140
- - [ ] **Step 4: Re-export from Store.ts**
141
-
142
- Edit `src/store/Store.ts`. At the bottom of the file, add:
143
-
144
- ```typescript
145
- export {
146
- focusedParticipantAtom,
147
- focusedMessageAtom,
148
- setFocusedParticipantAtom,
149
- setFocusedMessageAtom,
150
- } from "./keyboardAtoms";
151
- export type { FocusedMessageKey } from "./keyboardAtoms";
152
- ```
153
-
154
- - [ ] **Step 5: Run tests to verify pass**
155
-
156
- Run: `bun run test src/store/keyboardAtoms.spec.ts`
157
- Expected: PASS (all 4 cases).
158
-
159
- - [ ] **Step 6: Commit**
160
-
161
- ```bash
162
- git add src/store/keyboardAtoms.ts src/store/keyboardAtoms.spec.ts src/store/Store.ts
163
- git commit -m "feat(store): add focused participant/message atoms for keyboard editing"
164
- ```
165
-
166
- ---
167
-
168
- ## Task 2: Ring builders
169
-
170
- **Files:**
171
- - Create: `src/components/DiagramFrame/SeqDiagram/keyboard/rings.ts`
172
- - Create: `src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts`
173
-
174
- Goal: two pure functions that walk the parse tree in DSL order. The participant ring returns participant names (strings). The message ring returns entries shaped to match `focusedMessageAtom` (`{ start, end }`) — the text position of the message's label in the DSL, so the ring items can be matched back to `EditableSpan`s via `pendingEditableRangeAtom`-style coordinates.
175
-
176
- - [ ] **Step 1: Write the failing test**
177
-
178
- Create `src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts`:
179
-
180
- ```typescript
181
- import { Fixture } from "@/../test/unit/parser/fixture/Fixture";
182
- import { RootContext } from "@/parser";
183
- import { buildParticipantRing, buildMessageRing } from "./rings";
184
-
185
- describe("rings.buildParticipantRing", () => {
186
- it("returns explicit participants in DSL order", () => {
187
- const root = RootContext("A\nB\nC\nA->B.m1\n");
188
- expect(buildParticipantRing(root)).toEqual(["A", "B", "C"]);
189
- });
190
-
191
- it("skips the implicit _STARTER_", () => {
192
- const root = RootContext("A->B.m1\n");
193
- const ring = buildParticipantRing(root);
194
- expect(ring).not.toContain("_STARTER_");
195
- expect(ring).toEqual(["A", "B"]);
196
- });
197
-
198
- it("returns empty for empty code", () => {
199
- expect(buildParticipantRing(null)).toEqual([]);
200
- });
201
- });
202
-
203
- describe("rings.buildMessageRing", () => {
204
- it("returns top-level messages in DSL order", () => {
205
- const root = RootContext("A->B.m1\nA->B.m2\n");
206
- const ring = buildMessageRing(root);
207
- expect(ring).toHaveLength(2);
208
- expect(ring[0].signature).toBe("m1");
209
- expect(ring[1].signature).toBe("m2");
210
- });
211
-
212
- it("walks into alt/loop fragments in order", () => {
213
- const code = "A->B.m1\nalt x\n A->B.m2\nelse y\n A->B.m3\nend\nA->B.m4\n";
214
- const root = RootContext(code);
215
- const ring = buildMessageRing(root);
216
- const sigs = ring.map((r) => r.signature);
217
- expect(sigs).toEqual(["m1", "m2", "m3", "m4"]);
218
- });
219
-
220
- it("each ring entry exposes label start/end and from/to", () => {
221
- const root = RootContext("A->B.m1\n");
222
- const ring = buildMessageRing(root);
223
- expect(ring[0].from).toBe("A");
224
- expect(ring[0].to).toBe("B");
225
- expect(typeof ring[0].labelStart).toBe("number");
226
- expect(typeof ring[0].labelEnd).toBe("number");
227
- expect(ring[0].labelEnd).toBeGreaterThanOrEqual(ring[0].labelStart);
228
- });
229
-
230
- it("returns empty for null rootContext", () => {
231
- expect(buildMessageRing(null)).toEqual([]);
232
- });
233
- });
234
- ```
235
-
236
- - [ ] **Step 2: Run test to verify it fails**
237
-
238
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts`
239
- Expected: FAIL — module does not exist.
240
-
241
- - [ ] **Step 3: Implement `buildParticipantRing`**
242
-
243
- Create `src/components/DiagramFrame/SeqDiagram/keyboard/rings.ts`:
244
-
245
- ```typescript
246
- import { OrderedParticipants, _STARTER_ } from "@/parser/OrderedParticipants";
247
- import { AllMessages } from "@/parser/MessageCollector";
248
-
249
- export type MessageRingEntry = {
250
- from: string;
251
- to: string;
252
- signature: string;
253
- labelStart: number;
254
- labelEnd: number;
255
- blockContext: any;
256
- insertIndex: number;
257
- };
258
-
259
- export const buildParticipantRing = (rootContext: any): string[] => {
260
- if (!rootContext) return [];
261
- return OrderedParticipants(rootContext)
262
- .map((p) => p.name)
263
- .filter((name) => name !== _STARTER_);
264
- };
265
-
266
- export const buildMessageRing = (rootContext: any): MessageRingEntry[] => {
267
- if (!rootContext) return [];
268
- // Walk the parse tree (not AllMessages — we need ctx to compute positions
269
- // and the containing block). Recursive walk covers fragments.
270
- const out: MessageRingEntry[] = [];
271
- walkBlock(rootContext.block?.(), out);
272
- return out;
273
- };
274
-
275
- const walkBlock = (blockCtx: any, out: MessageRingEntry[]) => {
276
- if (!blockCtx) return;
277
- const stats: any[] = blockCtx.stat?.() ?? [];
278
- stats.forEach((stat, index) => {
279
- const message = stat.message?.();
280
- if (message) {
281
- const from = message.From?.()?.getText?.() ?? "";
282
- const to = message.Owner?.()?.getText?.() ?? "";
283
- const sigCtx = message.signature?.() ?? message;
284
- const signature = sigCtx?.getText?.() ?? "";
285
- const labelStart = sigCtx?.start?.start ?? -1;
286
- const labelEnd = sigCtx?.stop?.stop ?? -1;
287
- if (labelStart !== -1) {
288
- out.push({
289
- from,
290
- to,
291
- signature,
292
- labelStart,
293
- labelEnd,
294
- blockContext: blockCtx,
295
- insertIndex: index,
296
- });
297
- }
298
- }
299
- // Recurse into fragment children (alt/loop/par/opt/section/critical/tcf)
300
- const fragments = [
301
- stat.alt?.(),
302
- stat.loop?.(),
303
- stat.par?.(),
304
- stat.opt?.(),
305
- stat.section?.(),
306
- stat.critical?.(),
307
- stat.tcf?.(),
308
- ].filter(Boolean);
309
- for (const frag of fragments) {
310
- // Fragment children expose one or more nested blocks — walk each.
311
- const nestedBlocks = collectFragmentBlocks(frag);
312
- for (const block of nestedBlocks) {
313
- walkBlock(block, out);
314
- }
315
- }
316
- });
317
- };
318
-
319
- const collectFragmentBlocks = (fragment: any): any[] => {
320
- const blocks: any[] = [];
321
- // Standard fragments expose .block() (single) or .braceBlock() (alt/par with multiple)
322
- const single = fragment?.block?.();
323
- if (single) blocks.push(single);
324
- const multi = fragment?.braceBlock?.() ?? [];
325
- if (Array.isArray(multi)) blocks.push(...multi);
326
- else if (multi) blocks.push(multi);
327
- return blocks;
328
- };
329
- ```
330
-
331
- - [ ] **Step 4: Run tests to verify pass**
332
-
333
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts`
334
- Expected: PASS (all 6 cases).
335
-
336
- - [ ] **Step 5: If any fragment-walk test fails**
337
-
338
- The ANTLR generated accessors may differ between fragment types. Fix by reading `src/g4/sequenceParser.g4` (or the generated parser) to find the actual child accessor names. Update `collectFragmentBlocks` accordingly. Re-run.
339
-
340
- - [ ] **Step 6: Commit**
341
-
342
- ```bash
343
- git add src/components/DiagramFrame/SeqDiagram/keyboard/rings.ts src/components/DiagramFrame/SeqDiagram/keyboard/rings.spec.ts
344
- git commit -m "feat(keyboard): add participant and message ring builders"
345
- ```
346
-
347
- ---
348
-
349
- ## Task 3: Extend `insertParticipantIntoDsl` to return label position
350
-
351
- **Files:**
352
- - Modify: `src/utils/participantInsertTransform.ts`
353
- - Modify: `src/utils/participantInsertTransform.spec.ts` (or create)
354
- - Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantInsertControls.tsx`
355
-
356
- The helper currently returns the new code as a string. To auto-open the new participant in edit mode after a Tab-sibling insertion, we need the label range.
357
-
358
- - [ ] **Step 1: Find the existing spec or create one**
359
-
360
- Run: `ls src/utils/participantInsertTransform.spec.ts 2>/dev/null && echo exists || echo missing`
361
- If missing, create it in Step 2.
362
-
363
- - [ ] **Step 2: Write the failing test**
364
-
365
- In `src/utils/participantInsertTransform.spec.ts` add (or create with):
366
-
367
- ```typescript
368
- import { RootContext } from "@/parser";
369
- import { insertParticipantIntoDsl } from "./participantInsertTransform";
370
-
371
- describe("insertParticipantIntoDsl", () => {
372
- it("returns the new code plus the label position of the inserted name", () => {
373
- const code = "A\nB\nA->B.m1\n";
374
- const rootContext = RootContext(code);
375
- const result = insertParticipantIntoDsl({
376
- code,
377
- rootContext,
378
- insertIndex: 1,
379
- name: "X",
380
- type: "default",
381
- });
382
-
383
- expect(typeof result).toBe("object");
384
- expect(result.code).toContain("X");
385
- const [start, end] = result.labelPosition;
386
- expect(result.code.slice(start, end + 1)).toBe("X");
387
- });
388
- });
389
- ```
390
-
391
- - [ ] **Step 3: Run test to verify it fails**
392
-
393
- Run: `bun run test src/utils/participantInsertTransform.spec.ts`
394
- Expected: FAIL — `result.code` is undefined (currently the function returns a string).
395
-
396
- - [ ] **Step 4: Update `insertParticipantIntoDsl`**
397
-
398
- Edit `src/utils/participantInsertTransform.ts`. Replace the function body's return statements with a shape that includes `labelPosition`. The label position is the range of the newly-inserted `name` (which goes through `normalizeName`, so it may be quoted). Compute it by finding the new line in the returned code and locating `normalizedName` within it.
399
-
400
- Replace the two `return` branches at the bottom of the function with:
401
-
402
- ```typescript
403
- const normalizedName = normalizeName(name);
404
-
405
- if (head) {
406
- const headStart = head.start.start;
407
- const headEnd = head.stop.stop + 1;
408
- const starter = head.starterExp?.();
409
- const starterText = starter
410
- ? code.slice(starter.start.start, starter.stop.stop + 1)
411
- : "";
412
- const nextHead = starterText
413
- ? `${participantLines}\n${starterText}`
414
- : participantLines;
415
- const nextCode = code.slice(0, headStart) + nextHead + code.slice(headEnd);
416
- const labelStart = nextCode.indexOf(normalizedName, headStart);
417
- return {
418
- code: nextCode,
419
- labelPosition: [labelStart, labelStart + normalizedName.length - 1] as [
420
- number,
421
- number,
422
- ],
423
- };
424
- }
425
-
426
- const insertionPoint = block
427
- ? block.start.start
428
- : title
429
- ? title.stop.stop + 1
430
- : 0;
431
- const prefix = code.slice(0, insertionPoint);
432
- const suffix = code.slice(insertionPoint);
433
- const separator =
434
- prefix.length > 0 && !prefix.endsWith("\n") ? "\n" : "";
435
- const nextCode = `${prefix}${separator}${participantLines}\n${suffix}`;
436
- const labelStart = nextCode.indexOf(normalizedName, insertionPoint);
437
- return {
438
- code: nextCode,
439
- labelPosition: [labelStart, labelStart + normalizedName.length - 1] as [
440
- number,
441
- number,
442
- ],
443
- };
444
- ```
445
-
446
- Also update the return type annotation on the exported function to:
447
-
448
- ```typescript
449
- ): { code: string; labelPosition: [number, number] } => {
450
- ```
451
-
452
- - [ ] **Step 5: Run tests to verify pass**
453
-
454
- Run: `bun run test src/utils/participantInsertTransform.spec.ts`
455
- Expected: PASS.
456
-
457
- - [ ] **Step 6: Update call site in `ParticipantInsertControls.tsx`**
458
-
459
- Edit `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantInsertControls.tsx`. Change:
460
-
461
- ```typescript
462
- const nextCode = insertParticipantIntoDsl({
463
- code,
464
- rootContext,
465
- insertIndex,
466
- name,
467
- type: "default",
468
- });
469
- setCode(nextCode);
470
- onContentChange(nextCode);
471
- ```
472
-
473
- to:
474
-
475
- ```typescript
476
- const { code: nextCode } = insertParticipantIntoDsl({
477
- code,
478
- rootContext,
479
- insertIndex,
480
- name,
481
- type: "default",
482
- });
483
- setCode(nextCode);
484
- onContentChange(nextCode);
485
- ```
486
-
487
- - [ ] **Step 7: Run the full unit test suite to catch regressions**
488
-
489
- Run: `bun run test`
490
- Expected: PASS.
491
-
492
- - [ ] **Step 8: Commit**
493
-
494
- ```bash
495
- git add src/utils/participantInsertTransform.ts src/utils/participantInsertTransform.spec.ts src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantInsertControls.tsx
496
- git commit -m "refactor(insert): return labelPosition from insertParticipantIntoDsl"
497
- ```
498
-
499
- ---
500
-
501
- ## Task 4: Extend `EditableSpan` with `onTabCreateSibling`
502
-
503
- **Files:**
504
- - Modify: `src/components/common/EditableSpan/EditableSpan.tsx`
505
- - Create or modify: `src/components/common/EditableSpan/EditableSpan.spec.tsx`
506
-
507
- Goal: when in edit mode and the parent provides `onTabCreateSibling`, pressing `Tab` should save the current text and call the callback with `"after"`; `Shift+Tab` with `"before"`. If current text is empty, Tab is a no-op (empty-text guard from the spec).
508
-
509
- - [ ] **Step 1: Check if spec file exists**
510
-
511
- Run: `ls src/components/common/EditableSpan/EditableSpan.spec.tsx 2>/dev/null || echo missing`
512
-
513
- If missing, create it in Step 2 with the full content. If it exists, append the new test cases.
514
-
515
- - [ ] **Step 2: Write the failing test**
516
-
517
- Create or extend `src/components/common/EditableSpan/EditableSpan.spec.tsx`:
518
-
519
- ```typescript
520
- import { render, screen } from "@testing-library/react";
521
- import userEvent from "@testing-library/user-event";
522
- import { EditableSpan } from "./EditableSpan";
523
-
524
- describe("EditableSpan onTabCreateSibling", () => {
525
- it("calls callback with 'after' on Tab in edit mode", async () => {
526
- const onSave = vi.fn();
527
- const onTabCreateSibling = vi.fn();
528
- render(
529
- <EditableSpan
530
- text="hello"
531
- onSave={onSave}
532
- onTabCreateSibling={onTabCreateSibling}
533
- autoEditToken={1}
534
- />,
535
- );
536
- const span = screen.getByText("hello");
537
- const user = userEvent.setup();
538
- await user.keyboard("world");
539
- await user.keyboard("{Tab}");
540
- expect(onSave).toHaveBeenCalled();
541
- expect(onTabCreateSibling).toHaveBeenCalledWith("after");
542
- // the parent's span content should still be "world" (value at save)
543
- expect(onSave.mock.calls.at(-1)?.[0]).toContain("world");
544
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
545
- span;
546
- });
547
-
548
- it("calls callback with 'before' on Shift+Tab in edit mode", async () => {
549
- const onSave = vi.fn();
550
- const onTabCreateSibling = vi.fn();
551
- render(
552
- <EditableSpan
553
- text="hello"
554
- onSave={onSave}
555
- onTabCreateSibling={onTabCreateSibling}
556
- autoEditToken={1}
557
- />,
558
- );
559
- const user = userEvent.setup();
560
- await user.keyboard("x");
561
- await user.keyboard("{Shift>}{Tab}{/Shift}");
562
- expect(onTabCreateSibling).toHaveBeenCalledWith("before");
563
- });
564
-
565
- it("empty-text guard: Tab on empty content is a no-op", async () => {
566
- const onSave = vi.fn();
567
- const onTabCreateSibling = vi.fn();
568
- render(
569
- <EditableSpan
570
- text=""
571
- onSave={onSave}
572
- onTabCreateSibling={onTabCreateSibling}
573
- autoEditToken={1}
574
- />,
575
- );
576
- const user = userEvent.setup();
577
- await user.keyboard("{Tab}");
578
- expect(onTabCreateSibling).not.toHaveBeenCalled();
579
- });
580
-
581
- it("falls back to default Tab behavior when prop not provided", async () => {
582
- const onSave = vi.fn();
583
- render(<EditableSpan text="hello" onSave={onSave} autoEditToken={1} />);
584
- const user = userEvent.setup();
585
- await user.keyboard("x");
586
- await user.keyboard("{Tab}");
587
- expect(onSave).toHaveBeenCalled();
588
- });
589
- });
590
- ```
591
-
592
- - [ ] **Step 3: Run test to verify it fails**
593
-
594
- Run: `bun run test src/components/common/EditableSpan/EditableSpan.spec.tsx`
595
- Expected: FAIL — prop does not exist, callback never invoked.
596
-
597
- - [ ] **Step 4: Extend `EditableSpan`**
598
-
599
- Edit `src/components/common/EditableSpan/EditableSpan.tsx`.
600
-
601
- a) Update `EditableSpanProps`:
602
-
603
- ```typescript
604
- export interface EditableSpanProps {
605
- text: string;
606
- isEditable?: boolean;
607
- className?: string;
608
- onSave: (newText: string) => void;
609
- title?: string;
610
- autoEditToken?: number;
611
- selectAllOnEdit?: boolean;
612
- onTabCreateSibling?: (direction: "before" | "after") => void;
613
- }
614
- ```
615
-
616
- b) Destructure the new prop in the component signature:
617
-
618
- ```typescript
619
- export const EditableSpan = ({
620
- text,
621
- isEditable = true,
622
- className = "",
623
- onSave,
624
- title = "Click to edit",
625
- autoEditToken,
626
- selectAllOnEdit = false,
627
- onTabCreateSibling,
628
- }: EditableSpanProps) => {
629
- ```
630
-
631
- c) Replace the existing `handleKeydown` Tab branch. The current `handleKeydown` does not handle Tab explicitly (Tab is handled in `handleKeyup` which saves). We need to intercept Tab in `handleKeydown` BEFORE the browser moves focus. Inside the `if (editing)` block in `handleKeydown`, add this BEFORE the existing `Enter` handler:
632
-
633
- ```typescript
634
- if (e.key === "Tab" && onTabCreateSibling) {
635
- e.preventDefault();
636
- e.stopPropagation();
637
- const currentText = spanRef.current?.innerText?.trim() ?? "";
638
- if (currentText === "") {
639
- // Empty-text guard — do nothing.
640
- return;
641
- }
642
- cancelRef.current = true;
643
- setEditing(false);
644
- setIsHovered(false);
645
- saveText();
646
- onTabCreateSibling(e.shiftKey ? "before" : "after");
647
- return;
648
- }
649
- ```
650
-
651
- d) Also update `handleKeyup` so that when `onTabCreateSibling` is provided, Tab in keyup is ignored (keydown already handled it):
652
-
653
- ```typescript
654
- const handleKeyup = (e: KeyboardEvent) => {
655
- if (!isEditable) return;
656
- if (!editing) return;
657
-
658
- if (e.key === "Tab" && onTabCreateSibling) {
659
- // Handled in keydown.
660
- return;
661
- }
662
-
663
- if (e.key === "Enter" || e.key === "Tab") {
664
- cancelRef.current = true;
665
- setEditing(false);
666
- setIsHovered(false);
667
- saveText();
668
- }
669
- };
670
- ```
671
-
672
- - [ ] **Step 5: Run tests to verify pass**
673
-
674
- Run: `bun run test src/components/common/EditableSpan/EditableSpan.spec.tsx`
675
- Expected: PASS (all 4 cases).
676
-
677
- - [ ] **Step 6: Run the full unit test suite to catch regressions**
678
-
679
- Run: `bun run test`
680
- Expected: PASS. `EditableSpan` is used by several components — this step confirms no existing tests regressed.
681
-
682
- - [ ] **Step 7: Commit**
683
-
684
- ```bash
685
- git add src/components/common/EditableSpan/EditableSpan.tsx src/components/common/EditableSpan/EditableSpan.spec.tsx
686
- git commit -m "feat(editable-span): add onTabCreateSibling prop for Miro-style sibling creation"
687
- ```
688
-
689
- ---
690
-
691
- ## Task 5: Wire `onTabCreateSibling` into `ParticipantLabel`
692
-
693
- **Files:**
694
- - Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx`
695
- - Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx`
696
-
697
- Goal: when the user Tabs while editing a participant label, insert a new participant before/after the current one and auto-open it in edit mode.
698
-
699
- - [ ] **Step 1: Write the failing test**
700
-
701
- Append to `ParticipantLabel.spec.tsx`:
702
-
703
- ```typescript
704
- describe("ParticipantLabel keyboard sibling creation", () => {
705
- it("Tab while editing inserts a new participant after and auto-opens it", async () => {
706
- // Fixture: a diagram with participants A and B
707
- const Fixture = ({ code }: { code: string }) => {
708
- const store = createStore();
709
- store.set(codeAtom, code);
710
- return (
711
- <Provider store={store}>
712
- <DiagramFrame>
713
- <SeqDiagram />
714
- </DiagramFrame>
715
- </Provider>
716
- );
717
- };
718
- const { container } = render(<Fixture code="A\nB\n" />);
719
- const firstLabel = container.querySelector<HTMLElement>(
720
- ".participant[data-participant-id='A'] .editable-span-base",
721
- );
722
- expect(firstLabel).toBeTruthy();
723
- firstLabel!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
724
- const user = userEvent.setup();
725
- await user.keyboard("{Enter}Alpha{Tab}");
726
- // After Tab: a new participant should be inserted after A (auto-named).
727
- const all = container.querySelectorAll(".participant");
728
- expect(all.length).toBeGreaterThanOrEqual(3);
729
- });
730
- });
731
- ```
732
-
733
- *(The existing `ParticipantLabel.spec.tsx` imports and test harness can be reused — read the file first and adapt the fixture helper to match its style.)*
734
-
735
- - [ ] **Step 2: Run test to verify it fails**
736
-
737
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx`
738
- Expected: FAIL — Tab is saved into the existing participant, no new one created.
739
-
740
- - [ ] **Step 3: Add a helper for ring-aware insertion**
741
-
742
- Edit `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx`. Add imports at top:
743
-
744
- ```typescript
745
- import {
746
- codeAtom,
747
- modeAtom,
748
- onContentChangeAtom,
749
- pendingEditableRangeAtom,
750
- rootContextAtom,
751
- setFocusedParticipantAtom,
752
- RenderMode,
753
- } from "@/store/Store";
754
- import { insertParticipantIntoDsl } from "@/utils/participantInsertTransform";
755
- import { OrderedParticipants, _STARTER_ } from "@/parser/OrderedParticipants";
756
- ```
757
-
758
- - [ ] **Step 4: Implement the sibling creator**
759
-
760
- Inside the component body, after the existing atom hooks, add:
761
-
762
- ```typescript
763
- const rootContext = useAtomValue(rootContextAtom);
764
- const setPendingEditableRange = useSetAtom(pendingEditableRangeAtom);
765
- const setFocusedParticipant = useSetAtom(setFocusedParticipantAtom);
766
-
767
- const handleTabCreateSibling = (direction: "before" | "after") => {
768
- if (!rootContext) return;
769
- const names = OrderedParticipants(rootContext)
770
- .map((p) => p.name)
771
- .filter((n) => n !== _STARTER_);
772
- const existing = new Set(names);
773
- const currentIndex = names.indexOf(props.labelText);
774
- if (currentIndex === -1) return;
775
- const insertIndex =
776
- direction === "after" ? currentIndex + 1 : currentIndex;
777
- const autoName = generateParticipantName(existing);
778
- const { code: nextCode, labelPosition } = insertParticipantIntoDsl({
779
- code,
780
- rootContext,
781
- insertIndex,
782
- name: autoName,
783
- type: "default",
784
- });
785
- setCode(nextCode);
786
- onContentChange(nextCode);
787
- setPendingEditableRange({
788
- start: labelPosition[0],
789
- end: labelPosition[1],
790
- token: Date.now(),
791
- });
792
- setFocusedParticipant(autoName);
793
- };
794
- ```
795
-
796
- Also add the helper at module scope (above the component):
797
-
798
- ```typescript
799
- const generateParticipantName = (existing: Set<string>): string => {
800
- for (let i = 1; ; i++) {
801
- if (i <= 26) {
802
- const candidate = String.fromCharCode(64 + i);
803
- if (!existing.has(candidate)) return candidate;
804
- continue;
805
- }
806
- const fallback = `P${i - 26}`;
807
- if (!existing.has(fallback)) return fallback;
808
- }
809
- };
810
- ```
811
-
812
- *(This is duplicated from `ParticipantInsertControls.generateName` — it's 8 lines and shared extraction would require a new utility file, which we can do in a cleanup PR. For now, duplicate and note it.)*
813
-
814
- - [ ] **Step 5: Pass the handler to `EditableSpan`**
815
-
816
- In the returned JSX, add `onTabCreateSibling` to both `EditableSpan` usages:
817
-
818
- ```typescript
819
- <EditableSpan
820
- text={displayText}
821
- isEditable={props.assignee ? assigneeIsEditable : participantIsEditable}
822
- className={cn("name leading-4 right px-1")}
823
- onSave={
824
- props.assignee
825
- ? createCombinedSaveHandler(displayText)
826
- : createSaveHandler(props.labelPositions ?? [], props.labelText)
827
- }
828
- onTabCreateSibling={handleTabCreateSibling}
829
- title="Click to edit"
830
- />
831
- ```
832
-
833
- - [ ] **Step 6: Run test to verify it passes**
834
-
835
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx`
836
- Expected: PASS.
837
-
838
- - [ ] **Step 7: Run full unit suite**
839
-
840
- Run: `bun run test`
841
- Expected: PASS.
842
-
843
- - [ ] **Step 8: Commit**
844
-
845
- ```bash
846
- git add src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx
847
- git commit -m "feat(keyboard): Tab in participant label creates a sibling participant"
848
- ```
849
-
850
- ---
851
-
852
- ## Task 6: Wire `onTabCreateSibling` into `EditableLabelField` (messages)
853
-
854
- **Files:**
855
- - Modify: `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx`
856
- - Create: `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx`
857
-
858
- Goal: when the user Tabs while editing a message label, insert a new message in the same block, inheriting from/to from the current message, and auto-open the new label.
859
-
860
- This requires the caller (e.g. `MessageLabel`) to pass additional context: `blockContext`, `insertIndex`, `from`, `to`. We extend `EditableLabelFieldProps` with an optional `siblingContext` object that carries those fields.
861
-
862
- - [ ] **Step 1: Write the failing test**
863
-
864
- Create `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx`:
865
-
866
- ```typescript
867
- import { render } from "@testing-library/react";
868
- import userEvent from "@testing-library/user-event";
869
- import { Provider, createStore } from "jotai";
870
- import { codeAtom } from "@/store/Store";
871
- import { DiagramFrame } from "@/components/DiagramFrame/DiagramFrame";
872
- import { SeqDiagram } from "@/components/DiagramFrame/SeqDiagram/SeqDiagram";
873
-
874
- describe("EditableLabelField Tab-create-sibling", () => {
875
- it("Tab while editing a message creates another message A->B", async () => {
876
- const store = createStore();
877
- store.set(codeAtom, "A->B.m1\n");
878
- const { container } = render(
879
- <Provider store={store}>
880
- <DiagramFrame>
881
- <SeqDiagram />
882
- </DiagramFrame>
883
- </Provider>,
884
- );
885
- const label = container.querySelector<HTMLElement>(
886
- ".message .editable-span-base",
887
- );
888
- expect(label).toBeTruthy();
889
- // Click to select, then click to edit (branch behavior)
890
- label!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
891
- label!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
892
- const user = userEvent.setup();
893
- await user.keyboard("renamed{Tab}newLabel{Enter}");
894
- expect(store.get(codeAtom)).toMatch(/A->B\.renamed/);
895
- expect(store.get(codeAtom)).toMatch(/A->B\.newLabel/);
896
- });
897
- });
898
- ```
899
-
900
- - [ ] **Step 2: Run test to verify it fails**
901
-
902
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx`
903
- Expected: FAIL — the second message is not created.
904
-
905
- - [ ] **Step 3: Extend `EditableLabelFieldProps`**
906
-
907
- Edit `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx`. Update the interface:
908
-
909
- ```typescript
910
- interface EditableLabelFieldProps {
911
- text: string;
912
- position: [number, number];
913
- normalizeText?: (text: string) => string;
914
- className?: string;
915
- title?: string;
916
- siblingContext?: {
917
- from: string;
918
- to: string;
919
- blockContext: any;
920
- insertIndex: number;
921
- };
922
- }
923
- ```
924
-
925
- - [ ] **Step 4: Implement the handler**
926
-
927
- Add imports at the top of the file:
928
-
929
- ```typescript
930
- import { insertMessageInDsl } from "@/utils/insertMessageInDsl";
931
- ```
932
-
933
- Inside the component body (after the existing hooks), add:
934
-
935
- ```typescript
936
- const handleTabCreateSibling = (direction: "before" | "after") => {
937
- if (!siblingContext) return;
938
- const { from, to, blockContext, insertIndex } = siblingContext;
939
- const effectiveIndex = direction === "after" ? insertIndex + 1 : insertIndex;
940
- const result = insertMessageInDsl({
941
- code,
942
- from,
943
- to,
944
- blockContext,
945
- insertIndex: effectiveIndex,
946
- });
947
- setCode(result.code);
948
- onContentChange(result.code);
949
- setPendingEditableRange({
950
- start: result.labelPosition[0],
951
- end: result.labelPosition[1],
952
- token: Date.now(),
953
- });
954
- };
955
- ```
956
-
957
- And add `siblingContext` to the destructured props, and `onTabCreateSibling` to the `EditableSpan` render:
958
-
959
- ```typescript
960
- export const EditableLabelField = ({
961
- text,
962
- position,
963
- normalizeText,
964
- className,
965
- title = "Click to edit",
966
- siblingContext,
967
- }: EditableLabelFieldProps) => {
968
- // ...existing hooks...
969
- return (
970
- <EditableSpan
971
- text={formattedText}
972
- isEditable={isEditable}
973
- className={cn(className)}
974
- onSave={handleSave}
975
- title={title}
976
- autoEditToken={shouldAutoEdit}
977
- onTabCreateSibling={siblingContext ? handleTabCreateSibling : undefined}
978
- />
979
- );
980
- };
981
- ```
982
-
983
- - [ ] **Step 5: Pass `siblingContext` from `MessageLabel`**
984
-
985
- Edit `src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx`. Extend its props to accept the sibling context and forward it:
986
-
987
- ```typescript
988
- export const MessageLabel = (props: {
989
- labelText: string;
990
- labelPosition: [number, number];
991
- normalizeText?: (text: string) => string;
992
- className?: string;
993
- siblingContext?: {
994
- from: string;
995
- to: string;
996
- blockContext: any;
997
- insertIndex: number;
998
- };
999
- }) => {
1000
- return (
1001
- <EditableLabelField
1002
- text={props.labelText}
1003
- position={props.labelPosition}
1004
- normalizeText={props.normalizeText}
1005
- className={cn("px-1 right", props.className)}
1006
- siblingContext={props.siblingContext}
1007
- />
1008
- );
1009
- };
1010
- ```
1011
-
1012
- - [ ] **Step 6: Forward context from `Interaction` and `InteractionAsync`**
1013
-
1014
- Read `Interaction.tsx`, find where `MessageLabel` is rendered, and add:
1015
-
1016
- ```typescript
1017
- siblingContext={{
1018
- from: /* owner / from name for this message */,
1019
- to: /* target / to name for this message */,
1020
- blockContext: props.blockContext,
1021
- insertIndex: props.insertIndex,
1022
- }}
1023
- ```
1024
-
1025
- The blockContext and insertIndex should already be available as props in `Interaction.tsx` (they are used by GapHandleZone). Verify by reading `Statement.tsx` which renders `Interaction`. If `insertIndex`/`blockContext` are not present as props, thread them through from `Block.tsx` → `Statement.tsx` → `Interaction.tsx` (Block already has the block context and the index of each child).
1026
-
1027
- Apply the same change to `InteractionAsync.tsx` / `Interaction-async.tsx`.
1028
-
1029
- - [ ] **Step 7: Run the new spec**
1030
-
1031
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx`
1032
- Expected: PASS.
1033
-
1034
- - [ ] **Step 8: Run the full unit test suite**
1035
-
1036
- Run: `bun run test`
1037
- Expected: PASS.
1038
-
1039
- - [ ] **Step 9: Commit**
1040
-
1041
- ```bash
1042
- git add src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx \
1043
- src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx \
1044
- src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.spec.tsx \
1045
- src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx \
1046
- src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/InteractionAsync/Interaction-async.tsx
1047
- git commit -m "feat(keyboard): Tab in message label creates a sibling message with same from/to"
1048
- ```
1049
-
1050
- ---
1051
-
1052
- ## Task 7: `useDiagramKeyboard` hook
1053
-
1054
- **Files:**
1055
- - Create: `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts`
1056
- - Create: `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1057
-
1058
- Goal: a hook that installs a keydown listener on a provided container `ref`, reading the current focused atom and dispatching arrow-key navigation. Only active when no element in the container is in edit mode (detected via `document.activeElement?.isContentEditable`).
1059
-
1060
- - [ ] **Step 1: Write the failing test**
1061
-
1062
- Create `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`:
1063
-
1064
- ```typescript
1065
- import { render, fireEvent } from "@testing-library/react";
1066
- import { Provider, createStore } from "jotai";
1067
- import {
1068
- codeAtom,
1069
- focusedParticipantAtom,
1070
- focusedMessageAtom,
1071
- } from "@/store/Store";
1072
- import { SeqDiagram } from "../SeqDiagram";
1073
- import { DiagramFrame } from "@/components/DiagramFrame/DiagramFrame";
1074
-
1075
- const renderWith = (code: string) => {
1076
- const store = createStore();
1077
- store.set(codeAtom, code);
1078
- const utils = render(
1079
- <Provider store={store}>
1080
- <DiagramFrame>
1081
- <SeqDiagram />
1082
- </DiagramFrame>
1083
- </Provider>,
1084
- );
1085
- return { store, ...utils };
1086
- };
1087
-
1088
- describe("useDiagramKeyboard", () => {
1089
- it("Right arrow moves to next participant", () => {
1090
- const { store, container } = renderWith("A\nB\nC\nA->B.m1\n");
1091
- store.set(focusedParticipantAtom, "A");
1092
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1093
- expect(diagram).toBeTruthy();
1094
- fireEvent.keyDown(diagram!, { key: "ArrowRight" });
1095
- expect(store.get(focusedParticipantAtom)).toBe("B");
1096
- });
1097
-
1098
- it("Right arrow from last participant wraps to first", () => {
1099
- const { store, container } = renderWith("A\nB\nC\nA->B.m1\n");
1100
- store.set(focusedParticipantAtom, "C");
1101
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1102
- fireEvent.keyDown(diagram!, { key: "ArrowRight" });
1103
- expect(store.get(focusedParticipantAtom)).toBe("A");
1104
- });
1105
-
1106
- it("Left arrow wraps backwards", () => {
1107
- const { store, container } = renderWith("A\nB\nC\n");
1108
- store.set(focusedParticipantAtom, "A");
1109
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1110
- fireEvent.keyDown(diagram!, { key: "ArrowLeft" });
1111
- expect(store.get(focusedParticipantAtom)).toBe("C");
1112
- });
1113
-
1114
- it("Down from participant drops into messages ring", () => {
1115
- const { store, container } = renderWith("A\nB\nA->B.m1\n");
1116
- store.set(focusedParticipantAtom, "A");
1117
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1118
- fireEvent.keyDown(diagram!, { key: "ArrowDown" });
1119
- expect(store.get(focusedParticipantAtom)).toBeNull();
1120
- expect(store.get(focusedMessageAtom)).not.toBeNull();
1121
- });
1122
-
1123
- it("Down in messages moves to next message", () => {
1124
- const { store, container } = renderWith("A->B.m1\nA->B.m2\n");
1125
- // Focus first message
1126
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1127
- fireEvent.keyDown(diagram!, { key: "ArrowDown" });
1128
- const first = store.get(focusedMessageAtom);
1129
- fireEvent.keyDown(diagram!, { key: "ArrowDown" });
1130
- const second = store.get(focusedMessageAtom);
1131
- expect(second).not.toEqual(first);
1132
- });
1133
-
1134
- it("Up from first message jumps back to the source participant", () => {
1135
- const { store, container } = renderWith("A->B.m1\n");
1136
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1137
- fireEvent.keyDown(diagram!, { key: "ArrowDown" }); // into messages
1138
- fireEvent.keyDown(diagram!, { key: "ArrowUp" });
1139
- expect(store.get(focusedParticipantAtom)).toBe("A");
1140
- expect(store.get(focusedMessageAtom)).toBeNull();
1141
- });
1142
-
1143
- it("arrow keys are ignored when a contenteditable has focus", () => {
1144
- const { store, container } = renderWith("A\nB\n");
1145
- store.set(focusedParticipantAtom, "A");
1146
- // Create a contenteditable and focus it
1147
- const editable = document.createElement("span");
1148
- editable.contentEditable = "true";
1149
- container.appendChild(editable);
1150
- editable.focus();
1151
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1152
- fireEvent.keyDown(diagram!, { key: "ArrowRight" });
1153
- expect(store.get(focusedParticipantAtom)).toBe("A"); // unchanged
1154
- });
1155
- });
1156
- ```
1157
-
1158
- - [ ] **Step 2: Run test to verify it fails**
1159
-
1160
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1161
- Expected: FAIL — hook not yet implemented.
1162
-
1163
- - [ ] **Step 3: Implement the hook**
1164
-
1165
- Create `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts`:
1166
-
1167
- ```typescript
1168
- import { useEffect, RefObject } from "react";
1169
- import { useAtomValue, useSetAtom } from "jotai";
1170
- import {
1171
- rootContextAtom,
1172
- focusedParticipantAtom,
1173
- focusedMessageAtom,
1174
- setFocusedParticipantAtom,
1175
- setFocusedMessageAtom,
1176
- } from "@/store/Store";
1177
- import { buildParticipantRing, buildMessageRing } from "./rings";
1178
-
1179
- export const useDiagramKeyboard = (
1180
- containerRef: RefObject<HTMLElement | null>,
1181
- ) => {
1182
- const rootContext = useAtomValue(rootContextAtom);
1183
- const focusedParticipant = useAtomValue(focusedParticipantAtom);
1184
- const focusedMessage = useAtomValue(focusedMessageAtom);
1185
- const setFocusedParticipant = useSetAtom(setFocusedParticipantAtom);
1186
- const setFocusedMessage = useSetAtom(setFocusedMessageAtom);
1187
-
1188
- useEffect(() => {
1189
- const container = containerRef.current;
1190
- if (!container) return;
1191
-
1192
- const onKeyDown = (event: KeyboardEvent) => {
1193
- // Ignore if a contenteditable is focused (edit mode).
1194
- const active = document.activeElement as HTMLElement | null;
1195
- if (active?.isContentEditable) return;
1196
-
1197
- const key = event.key;
1198
- if (
1199
- key !== "ArrowLeft" &&
1200
- key !== "ArrowRight" &&
1201
- key !== "ArrowUp" &&
1202
- key !== "ArrowDown"
1203
- ) {
1204
- return;
1205
- }
1206
-
1207
- const participants = buildParticipantRing(rootContext);
1208
- const messages = buildMessageRing(rootContext);
1209
-
1210
- // Participant ring
1211
- if (focusedParticipant !== null) {
1212
- const idx = participants.indexOf(focusedParticipant);
1213
- if (idx === -1) return;
1214
- if (key === "ArrowRight") {
1215
- event.preventDefault();
1216
- setFocusedParticipant(participants[(idx + 1) % participants.length]);
1217
- return;
1218
- }
1219
- if (key === "ArrowLeft") {
1220
- event.preventDefault();
1221
- setFocusedParticipant(
1222
- participants[(idx - 1 + participants.length) % participants.length],
1223
- );
1224
- return;
1225
- }
1226
- if (key === "ArrowDown") {
1227
- if (messages.length === 0) return;
1228
- event.preventDefault();
1229
- setFocusedMessage({
1230
- start: messages[0].labelStart,
1231
- end: messages[0].labelEnd,
1232
- });
1233
- return;
1234
- }
1235
- return;
1236
- }
1237
-
1238
- // Message ring
1239
- if (focusedMessage !== null) {
1240
- const idx = messages.findIndex(
1241
- (m) =>
1242
- m.labelStart === focusedMessage.start &&
1243
- m.labelEnd === focusedMessage.end,
1244
- );
1245
- if (idx === -1) return;
1246
- if (key === "ArrowDown") {
1247
- event.preventDefault();
1248
- const next = messages[(idx + 1) % messages.length];
1249
- setFocusedMessage({ start: next.labelStart, end: next.labelEnd });
1250
- return;
1251
- }
1252
- if (key === "ArrowUp") {
1253
- event.preventDefault();
1254
- if (idx === 0) {
1255
- // Jump to the source participant of the first message.
1256
- setFocusedParticipant(messages[0].from);
1257
- return;
1258
- }
1259
- const prev = messages[idx - 1];
1260
- setFocusedMessage({ start: prev.labelStart, end: prev.labelEnd });
1261
- return;
1262
- }
1263
- return;
1264
- }
1265
-
1266
- // Nothing focused — ArrowDown enters the messages ring at index 0,
1267
- // ArrowRight/Left enters the participant ring at index 0.
1268
- if (key === "ArrowDown" && messages.length > 0) {
1269
- event.preventDefault();
1270
- setFocusedMessage({
1271
- start: messages[0].labelStart,
1272
- end: messages[0].labelEnd,
1273
- });
1274
- return;
1275
- }
1276
- if (
1277
- (key === "ArrowRight" || key === "ArrowLeft") &&
1278
- participants.length > 0
1279
- ) {
1280
- event.preventDefault();
1281
- setFocusedParticipant(participants[0]);
1282
- return;
1283
- }
1284
- };
1285
-
1286
- container.addEventListener("keydown", onKeyDown);
1287
- return () => container.removeEventListener("keydown", onKeyDown);
1288
- }, [
1289
- containerRef,
1290
- rootContext,
1291
- focusedParticipant,
1292
- focusedMessage,
1293
- setFocusedParticipant,
1294
- setFocusedMessage,
1295
- ]);
1296
- };
1297
- ```
1298
-
1299
- - [ ] **Step 4: Run the unit tests to verify pass**
1300
-
1301
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1302
- Expected: PASS (all 7 cases). If the "nothing focused → ArrowDown" test needs a fresh store, adjust the test setup.
1303
-
1304
- - [ ] **Step 5: Commit**
1305
-
1306
- ```bash
1307
- git add src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts \
1308
- src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
1309
- git commit -m "feat(keyboard): add useDiagramKeyboard hook for arrow-key navigation"
1310
- ```
1311
-
1312
- ---
1313
-
1314
- ## Task 8: Install the hook on `SeqDiagram` + tabIndex + auto-focus
1315
-
1316
- **Files:**
1317
- - Modify: `src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx`
1318
-
1319
- - [ ] **Step 1: Write the failing test**
1320
-
1321
- Append to `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`:
1322
-
1323
- ```typescript
1324
- describe("SeqDiagram container focus", () => {
1325
- it("focusing the container auto-focuses the first participant", () => {
1326
- const { store, container } = renderWith("A\nB\nC\n");
1327
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1328
- expect(diagram).toBeTruthy();
1329
- diagram!.focus();
1330
- expect(store.get(focusedParticipantAtom)).toBe("A");
1331
- });
1332
- });
1333
- ```
1334
-
1335
- - [ ] **Step 2: Run test to verify it fails**
1336
-
1337
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1338
- Expected: FAIL — container is not focusable yet.
1339
-
1340
- - [ ] **Step 3: Wire the hook and tabIndex**
1341
-
1342
- Edit `src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx`. Add imports:
1343
-
1344
- ```typescript
1345
- import { useDiagramKeyboard } from "./keyboard/useDiagramKeyboard";
1346
- import {
1347
- focusedParticipantAtom,
1348
- setFocusedParticipantAtom,
1349
- } from "@/store/Store";
1350
- import { buildParticipantRing } from "./keyboard/rings";
1351
- ```
1352
-
1353
- Inside the component body, add:
1354
-
1355
- ```typescript
1356
- useDiagramKeyboard(diagramRef);
1357
- const setFocusedParticipant = useSetAtom(setFocusedParticipantAtom);
1358
- const focusedParticipant = useAtomValue(focusedParticipantAtom);
1359
-
1360
- const handleContainerFocus = (event: React.FocusEvent<HTMLDivElement>) => {
1361
- // Only act when the container itself received focus (not a child).
1362
- if (event.target !== diagramRef.current) return;
1363
- if (focusedParticipant !== null) return;
1364
- const ring = buildParticipantRing(rootContext);
1365
- if (ring.length > 0) {
1366
- setFocusedParticipant(ring[0]);
1367
- }
1368
- };
1369
- ```
1370
-
1371
- Update the container `<div>`:
1372
-
1373
- ```typescript
1374
- <div
1375
- className={cn(
1376
- "zenuml sequence-diagram relative box-border text-left overflow-visible px-2.5",
1377
- theme,
1378
- props.className,
1379
- )}
1380
- style={props.style}
1381
- ref={diagramRef}
1382
- tabIndex={0}
1383
- onFocus={handleContainerFocus}
1384
- >
1385
- ```
1386
-
1387
- - [ ] **Step 4: Run test to verify pass**
1388
-
1389
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1390
- Expected: PASS.
1391
-
1392
- - [ ] **Step 5: Run full unit suite**
1393
-
1394
- Run: `bun run test`
1395
- Expected: PASS.
1396
-
1397
- - [ ] **Step 6: Commit**
1398
-
1399
- ```bash
1400
- git add src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx \
1401
- src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
1402
- git commit -m "feat(keyboard): tabIndex + auto-focus first participant on SeqDiagram"
1403
- ```
1404
-
1405
- ---
1406
-
1407
- ## Task 9: Focus ring visuals
1408
-
1409
- **Files:**
1410
- - Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx`
1411
- - Modify: `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx`
1412
- - Modify: `src/components/DiagramFrame/SeqDiagram/SeqDiagram.css`
1413
-
1414
- Goal: show a 2px sky-blue ring on the focused participant / message label without interfering with existing `.selected` styles.
1415
-
1416
- - [ ] **Step 1: Add CSS classes**
1417
-
1418
- Append to `src/components/DiagramFrame/SeqDiagram/SeqDiagram.css`:
1419
-
1420
- ```css
1421
- .participant.kb-focused {
1422
- outline: 2px solid rgb(56, 189, 248);
1423
- outline-offset: 2px;
1424
- }
1425
-
1426
- .editable-span-base.kb-focused {
1427
- outline: 2px solid rgb(56, 189, 248);
1428
- outline-offset: 2px;
1429
- border-radius: 2px;
1430
- }
1431
- ```
1432
-
1433
- - [ ] **Step 2: Apply class in `Participant.tsx`**
1434
-
1435
- Edit `Participant.tsx`. Import:
1436
-
1437
- ```typescript
1438
- import { focusedParticipantAtom } from "@/store/Store";
1439
- ```
1440
-
1441
- In the component, add:
1442
-
1443
- ```typescript
1444
- const focusedParticipant = useAtomValue(focusedParticipantAtom);
1445
- const isKeyboardFocused = focusedParticipant === props.entity.name;
1446
- ```
1447
-
1448
- Update the outer `div` className with `{ "kb-focused": isKeyboardFocused }` in the `cn(...)` call.
1449
-
1450
- - [ ] **Step 3: Apply class in `EditableLabelField.tsx`**
1451
-
1452
- Edit `EditableLabelField.tsx`. Add:
1453
-
1454
- ```typescript
1455
- import { focusedMessageAtom } from "@/store/Store";
1456
- ```
1457
-
1458
- In the component body:
1459
-
1460
- ```typescript
1461
- const focusedMessage = useAtomValue(focusedMessageAtom);
1462
- const isKeyboardFocused =
1463
- focusedMessage !== null &&
1464
- focusedMessage.start === position[0] &&
1465
- focusedMessage.end === position[1];
1466
- ```
1467
-
1468
- Update the `className` passed to `EditableSpan`:
1469
-
1470
- ```typescript
1471
- className={cn(className, { "kb-focused": isKeyboardFocused })}
1472
- ```
1473
-
1474
- - [ ] **Step 4: Manual smoke test**
1475
-
1476
- Run: `bun dev`
1477
- Open http://localhost:8080, focus the diagram, press `→`, confirm sky-blue ring moves between participants; press `↓`, confirm ring moves to message label.
1478
-
1479
- - [ ] **Step 5: Run unit suite**
1480
-
1481
- Run: `bun run test`
1482
- Expected: PASS.
1483
-
1484
- - [ ] **Step 6: Commit**
1485
-
1486
- ```bash
1487
- git add src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx \
1488
- src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx \
1489
- src/components/DiagramFrame/SeqDiagram/SeqDiagram.css
1490
- git commit -m "feat(keyboard): sky-blue focus ring for keyboard navigation"
1491
- ```
1492
-
1493
- ---
1494
-
1495
- ## Task 10: Enter on focused element opens edit mode
1496
-
1497
- **Files:**
1498
- - Modify: `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts`
1499
- - Modify: `src/components/DiagramFrame/SeqDiagram/MessageLayer/EditableLabelField.tsx`
1500
- - Modify: `src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx`
1501
-
1502
- Goal: `Enter` on a kb-focused participant / message opens its `EditableSpan` in edit mode. Reuse the `autoEditToken` plumbing via `pendingEditableRangeAtom` for messages, and directly via `autoEditToken` for participants.
1503
-
1504
- Strategy: the hook dispatches Enter by setting `pendingEditableRangeAtom` to the focused element's range. `EditableLabelField` already consumes `pendingEditableRangeAtom` to auto-open. For participants, we add the same pattern: `ParticipantLabel` starts editing when `pendingEditableRangeAtom` matches any of its `labelPositions`.
1505
-
1506
- - [ ] **Step 1: Write the failing test**
1507
-
1508
- Append to `useDiagramKeyboard.spec.tsx`:
1509
-
1510
- ```typescript
1511
- it("Enter on focused message opens its label for editing", () => {
1512
- const { store, container } = renderWith("A->B.m1\n");
1513
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1514
- fireEvent.keyDown(diagram!, { key: "ArrowDown" });
1515
- fireEvent.keyDown(diagram!, { key: "Enter" });
1516
- // pendingEditableRange should be set to the focused message range
1517
- const pending = store.get(pendingEditableRangeAtom);
1518
- expect(pending).not.toBeNull();
1519
- });
1520
- ```
1521
-
1522
- Add the import for `pendingEditableRangeAtom` at the top of the spec.
1523
-
1524
- - [ ] **Step 2: Run test to verify it fails**
1525
-
1526
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1527
- Expected: FAIL.
1528
-
1529
- - [ ] **Step 3: Extend the hook**
1530
-
1531
- Edit `useDiagramKeyboard.ts`. Add Enter handling. At the top of `onKeyDown`, after the contenteditable guard, before the arrow-key early return, add:
1532
-
1533
- ```typescript
1534
- if (key === "Enter") {
1535
- if (focusedMessage !== null) {
1536
- event.preventDefault();
1537
- setPendingEditableRange({
1538
- start: focusedMessage.start,
1539
- end: focusedMessage.end,
1540
- token: Date.now(),
1541
- });
1542
- return;
1543
- }
1544
- if (focusedParticipant !== null) {
1545
- event.preventDefault();
1546
- setPendingParticipantEdit({
1547
- name: focusedParticipant,
1548
- token: Date.now(),
1549
- });
1550
- return;
1551
- }
1552
- return;
1553
- }
1554
- ```
1555
-
1556
- Add the imports:
1557
-
1558
- ```typescript
1559
- import {
1560
- pendingEditableRangeAtom,
1561
- pendingParticipantEditAtom,
1562
- } from "@/store/Store";
1563
- ```
1564
-
1565
- And inside the hook, add `const setPendingEditableRange = useSetAtom(pendingEditableRangeAtom);` and `const setPendingParticipantEdit = useSetAtom(pendingParticipantEditAtom);`.
1566
-
1567
- - [ ] **Step 4: Add `pendingParticipantEditAtom` to `Store`**
1568
-
1569
- Edit `src/store/keyboardAtoms.ts`:
1570
-
1571
- ```typescript
1572
- export type PendingParticipantEdit = { name: string; token: number };
1573
- export const pendingParticipantEditAtom = atom<PendingParticipantEdit | null>(
1574
- null,
1575
- );
1576
- ```
1577
-
1578
- Re-export from `Store.ts`:
1579
-
1580
- ```typescript
1581
- export {
1582
- // ...
1583
- pendingParticipantEditAtom,
1584
- } from "./keyboardAtoms";
1585
- export type { PendingParticipantEdit } from "./keyboardAtoms";
1586
- ```
1587
-
1588
- - [ ] **Step 5: Consume in `ParticipantLabel`**
1589
-
1590
- Edit `ParticipantLabel.tsx`. Add:
1591
-
1592
- ```typescript
1593
- import { pendingParticipantEditAtom } from "@/store/Store";
1594
- ```
1595
-
1596
- Inside the component:
1597
-
1598
- ```typescript
1599
- const pendingParticipantEdit = useAtomValue(pendingParticipantEditAtom);
1600
- const autoEditToken =
1601
- pendingParticipantEdit?.name === props.labelText
1602
- ? pendingParticipantEdit.token
1603
- : undefined;
1604
- ```
1605
-
1606
- Pass `autoEditToken={autoEditToken}` to the `<EditableSpan>` render.
1607
-
1608
- - [ ] **Step 6: Run tests to verify pass**
1609
-
1610
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.spec.tsx`
1611
- Expected: PASS.
1612
-
1613
- - [ ] **Step 7: Commit**
1614
-
1615
- ```bash
1616
- git add src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts \
1617
- src/store/keyboardAtoms.ts \
1618
- src/store/Store.ts \
1619
- src/components/DiagramFrame/SeqDiagram/LifeLineLayer/ParticipantLabel.tsx \
1620
- src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
1621
- git commit -m "feat(keyboard): Enter on focused element opens edit mode"
1622
- ```
1623
-
1624
- ---
1625
-
1626
- ## Task 11: Escape blurs the diagram; Tab escapes outside edit mode
1627
-
1628
- **Files:**
1629
- - Modify: `src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts`
1630
-
1631
- Goal: `Esc` outside edit mode clears focus atoms and blurs the container. `Tab` outside edit mode lets the browser's default tabbing run (no `preventDefault`) so the user Tabs out to the next page widget.
1632
-
1633
- - [ ] **Step 1: Write the failing test**
1634
-
1635
- Append to `useDiagramKeyboard.spec.tsx`:
1636
-
1637
- ```typescript
1638
- it("Escape clears focus atoms and blurs the diagram", () => {
1639
- const { store, container } = renderWith("A\nB\n");
1640
- store.set(focusedParticipantAtom, "A");
1641
- const diagram = container.querySelector<HTMLElement>(".sequence-diagram");
1642
- diagram!.focus();
1643
- fireEvent.keyDown(diagram!, { key: "Escape" });
1644
- expect(store.get(focusedParticipantAtom)).toBeNull();
1645
- expect(document.activeElement).not.toBe(diagram);
1646
- });
1647
- ```
1648
-
1649
- - [ ] **Step 2: Run test to verify it fails**
1650
-
1651
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1652
- Expected: FAIL.
1653
-
1654
- - [ ] **Step 3: Implement Escape handling**
1655
-
1656
- Edit `useDiagramKeyboard.ts`. In `onKeyDown`, after the contenteditable guard, add:
1657
-
1658
- ```typescript
1659
- if (key === "Escape") {
1660
- event.preventDefault();
1661
- setFocusedParticipant(null);
1662
- setFocusedMessage(null);
1663
- (document.activeElement as HTMLElement | null)?.blur();
1664
- return;
1665
- }
1666
- ```
1667
-
1668
- Tab needs no explicit handling — the browser's default Tab behavior will move focus out of the diagram container naturally (since the container is `tabIndex=0` and nothing inside is captured).
1669
-
1670
- - [ ] **Step 4: Run tests to verify pass**
1671
-
1672
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1673
- Expected: PASS.
1674
-
1675
- - [ ] **Step 5: Commit**
1676
-
1677
- ```bash
1678
- git add src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.ts \
1679
- src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
1680
- git commit -m "feat(keyboard): Escape clears focus and blurs the diagram"
1681
- ```
1682
-
1683
- ---
1684
-
1685
- ## Task 12: Empty-diagram placeholder
1686
-
1687
- **Files:**
1688
- - Create: `src/components/DiagramFrame/SeqDiagram/keyboard/EmptyDiagramPlaceholder.tsx`
1689
- - Modify: `src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx`
1690
-
1691
- Goal: when there are no participants, render a focusable dashed box that says "Add participant". Enter creates a blank participant and auto-opens it.
1692
-
1693
- - [ ] **Step 1: Write the failing test**
1694
-
1695
- Append to `useDiagramKeyboard.spec.tsx`:
1696
-
1697
- ```typescript
1698
- it("empty diagram renders a focusable placeholder; Enter creates a participant", () => {
1699
- const { store, container } = renderWith("");
1700
- const placeholder = container.querySelector<HTMLElement>(
1701
- "[data-testid='empty-diagram-placeholder']",
1702
- );
1703
- expect(placeholder).toBeTruthy();
1704
- placeholder!.focus();
1705
- fireEvent.keyDown(placeholder!, { key: "Enter" });
1706
- // A participant should have been inserted.
1707
- const code = store.get(codeAtom);
1708
- expect(code.length).toBeGreaterThan(0);
1709
- });
1710
- ```
1711
-
1712
- - [ ] **Step 2: Run test to verify it fails**
1713
-
1714
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1715
- Expected: FAIL — placeholder does not exist.
1716
-
1717
- - [ ] **Step 3: Create the placeholder component**
1718
-
1719
- Create `src/components/DiagramFrame/SeqDiagram/keyboard/EmptyDiagramPlaceholder.tsx`:
1720
-
1721
- ```typescript
1722
- import { useAtom, useAtomValue, useSetAtom } from "jotai";
1723
- import {
1724
- codeAtom,
1725
- onContentChangeAtom,
1726
- pendingParticipantEditAtom,
1727
- rootContextAtom,
1728
- setFocusedParticipantAtom,
1729
- } from "@/store/Store";
1730
- import { insertParticipantIntoDsl } from "@/utils/participantInsertTransform";
1731
-
1732
- export const EmptyDiagramPlaceholder = () => {
1733
- const [code, setCode] = useAtom(codeAtom);
1734
- const onContentChange = useAtomValue(onContentChangeAtom);
1735
- const rootContext = useAtomValue(rootContextAtom);
1736
- const setPending = useSetAtom(pendingParticipantEditAtom);
1737
- const setFocusedParticipant = useSetAtom(setFocusedParticipantAtom);
1738
-
1739
- const handleAdd = () => {
1740
- const { code: nextCode } = insertParticipantIntoDsl({
1741
- code,
1742
- rootContext,
1743
- insertIndex: 0,
1744
- name: "A",
1745
- type: "default",
1746
- });
1747
- setCode(nextCode);
1748
- onContentChange(nextCode);
1749
- setFocusedParticipant("A");
1750
- setPending({ name: "A", token: Date.now() });
1751
- };
1752
-
1753
- return (
1754
- <div
1755
- data-testid="empty-diagram-placeholder"
1756
- tabIndex={0}
1757
- role="button"
1758
- aria-label="Add participant"
1759
- className="m-4 inline-flex items-center justify-center px-4 py-2 border-2 border-dashed border-slate-300 text-slate-500 rounded cursor-pointer focus:outline-none focus-visible:border-sky-400 focus-visible:text-sky-500"
1760
- onKeyDown={(e) => {
1761
- if (e.key === "Enter" || e.key === " ") {
1762
- e.preventDefault();
1763
- handleAdd();
1764
- }
1765
- }}
1766
- onClick={handleAdd}
1767
- >
1768
- + Add participant
1769
- </div>
1770
- );
1771
- };
1772
- ```
1773
-
1774
- - [ ] **Step 4: Render when the diagram is empty**
1775
-
1776
- Edit `SeqDiagram.tsx`. Import:
1777
-
1778
- ```typescript
1779
- import { EmptyDiagramPlaceholder } from "./keyboard/EmptyDiagramPlaceholder";
1780
- ```
1781
-
1782
- In the JSX, wrap the existing inner content. Before the existing layer rendering, add:
1783
-
1784
- ```typescript
1785
- const isEmpty =
1786
- coordinates.orderedParticipantNames().filter((n) => n !== "_STARTER_")
1787
- .length === 0;
1788
- ```
1789
-
1790
- And inside the `Dynamic` branch:
1791
-
1792
- ```tsx
1793
- {isEmpty ? (
1794
- <EmptyDiagramPlaceholder />
1795
- ) : (
1796
- <>
1797
- <LifeLineLayer /* ... */ />
1798
- <MessageLayer /* ... */ />
1799
- <LifeLineLayer /* ... */ />
1800
- </>
1801
- )}
1802
- ```
1803
-
1804
- - [ ] **Step 5: Run tests to verify pass**
1805
-
1806
- Run: `bun run test src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx`
1807
- Expected: PASS.
1808
-
1809
- - [ ] **Step 6: Run the full unit suite**
1810
-
1811
- Run: `bun run test`
1812
- Expected: PASS.
1813
-
1814
- - [ ] **Step 7: Commit**
1815
-
1816
- ```bash
1817
- git add src/components/DiagramFrame/SeqDiagram/keyboard/EmptyDiagramPlaceholder.tsx \
1818
- src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx \
1819
- src/components/DiagramFrame/SeqDiagram/keyboard/useDiagramKeyboard.spec.tsx
1820
- git commit -m "feat(keyboard): empty-diagram placeholder with focusable add-participant affordance"
1821
- ```
1822
-
1823
- ---
1824
-
1825
- ## Task 13: Playwright E2E fixture and test
1826
-
1827
- **Files:**
1828
- - Create: `e2e/fixtures/keyboard-editing.html`
1829
- - Create: `tests/keyboard-editing.spec.ts`
1830
-
1831
- - [ ] **Step 1: Create the fixture**
1832
-
1833
- Use an existing fixture as a template. Run:
1834
-
1835
- ```bash
1836
- ls e2e/fixtures/editable-label.html
1837
- ```
1838
-
1839
- Copy its structure. Create `e2e/fixtures/keyboard-editing.html` — seed the diagram with `A\nB\nA->B.hello\n`. Follow the exact pattern of `e2e/fixtures/editable-label.html` (same `<script>` setup, same zenuml bootstrap).
1840
-
1841
- - [ ] **Step 2: Write the E2E test**
1842
-
1843
- Create `tests/keyboard-editing.spec.ts`:
1844
-
1845
- ```typescript
1846
- import { test, expect } from "@playwright/test";
1847
-
1848
- test.describe("keyboard editing", () => {
1849
- test("Tab in, arrow-navigate, Enter to edit, Tab to create sibling", async ({
1850
- page,
1851
- }) => {
1852
- await page.goto("/e2e/fixtures/keyboard-editing.html");
1853
-
1854
- const diagram = page.locator(".sequence-diagram").first();
1855
- await diagram.focus();
1856
-
1857
- // First participant should have a kb-focused outline.
1858
- await expect(
1859
- page.locator(".participant[data-participant-id='A']"),
1860
- ).toHaveClass(/kb-focused/);
1861
-
1862
- // Right arrow moves to B.
1863
- await page.keyboard.press("ArrowRight");
1864
- await expect(
1865
- page.locator(".participant[data-participant-id='B']"),
1866
- ).toHaveClass(/kb-focused/);
1867
-
1868
- // Enter to edit, type a new name, Tab to spawn a sibling.
1869
- await page.keyboard.press("Enter");
1870
- await page.keyboard.type("Bravo");
1871
- await page.keyboard.press("Tab");
1872
- await page.keyboard.type("Charlie");
1873
- await page.keyboard.press("Enter");
1874
-
1875
- // Three participants should exist now.
1876
- await expect(page.locator(".participant")).toHaveCount(3);
1877
- await expect(
1878
- page.locator(".participant[data-participant-id='Charlie']"),
1879
- ).toBeVisible();
1880
-
1881
- // Move into messages ring.
1882
- await page.keyboard.press("ArrowLeft"); // back to Bravo
1883
- await page.keyboard.press("ArrowLeft"); // back to A
1884
- await page.keyboard.press("ArrowDown"); // into messages
1885
- await expect(page.locator(".message.kb-focused")).toBeVisible();
1886
-
1887
- // Enter to edit the message, then Tab to create a sibling.
1888
- await page.keyboard.press("Enter");
1889
- await page.keyboard.type("renamed");
1890
- await page.keyboard.press("Tab");
1891
- await page.keyboard.type("second");
1892
- await page.keyboard.press("Enter");
1893
-
1894
- // Two messages now.
1895
- await expect(page.locator(".message")).toHaveCount(2);
1896
- });
1897
-
1898
- test("Escape blurs the diagram", async ({ page }) => {
1899
- await page.goto("/e2e/fixtures/keyboard-editing.html");
1900
- const diagram = page.locator(".sequence-diagram").first();
1901
- await diagram.focus();
1902
- await page.keyboard.press("ArrowRight");
1903
- await page.keyboard.press("Escape");
1904
- await expect(
1905
- page.locator(".participant.kb-focused"),
1906
- ).toHaveCount(0);
1907
- });
1908
- });
1909
- ```
1910
-
1911
- - [ ] **Step 3: Run the E2E test**
1912
-
1913
- Run: `bun pw tests/keyboard-editing.spec.ts`
1914
- Expected: PASS. If the CSS class names in the fixture differ (e.g. `.message` selector), adjust the spec selectors to match what the rendered DOM actually uses.
1915
-
1916
- - [ ] **Step 4: Commit**
1917
-
1918
- ```bash
1919
- git add e2e/fixtures/keyboard-editing.html tests/keyboard-editing.spec.ts
1920
- git commit -m "test(e2e): keyboard editing flow — Tab, arrow nav, Tab-sibling"
1921
- ```
1922
-
1923
- ---
1924
-
1925
- ## Task 14: Lint, format, full test suite
1926
-
1927
- - [ ] **Step 1: Run lint**
1928
-
1929
- Run: `bun eslint`
1930
- Expected: no errors. Fix any introduced.
1931
-
1932
- - [ ] **Step 2: Run prettier**
1933
-
1934
- Run: `bun prettier`
1935
- Expected: no diff. If there is a diff, `git add` and amend-free commit it in Step 4.
1936
-
1937
- - [ ] **Step 3: Run unit tests**
1938
-
1939
- Run: `bun run test`
1940
- Expected: PASS across the whole suite.
1941
-
1942
- - [ ] **Step 4: Run E2E smoke tests**
1943
-
1944
- Run: `bun pw:smoke`
1945
- Expected: PASS.
1946
-
1947
- - [ ] **Step 5: If formatter produced a diff, commit it**
1948
-
1949
- ```bash
1950
- git add -u
1951
- git commit -m "style: prettier formatting for keyboard editing"
1952
- ```
1953
-
1954
- ---
1955
-
1956
- ## Self-Review (for plan author)
1957
-
1958
- **Spec coverage**
1959
-
1960
- - ✅ Two focus rings (participants, messages) — Tasks 1, 2, 7
1961
- - ✅ Arrow-key navigation — Task 7
1962
- - ✅ Enter enters edit mode — Task 10
1963
- - ✅ Esc blurs diagram — Task 11
1964
- - ✅ Tab escapes widget outside edit mode — Task 11 (relies on browser default; explicitly not prevented)
1965
- - ✅ Tab/Shift+Tab in edit mode creates sibling after/before — Tasks 4, 5, 6
1966
- - ✅ Participant Tab-sibling — Task 5
1967
- - ✅ Message Tab-sibling inherits from/to — Task 6
1968
- - ✅ Empty-text guard — Task 4
1969
- - ✅ Empty-diagram placeholder — Task 12
1970
- - ✅ Auto-open new element in edit mode — Tasks 5, 6 (via `pendingEditableRangeAtom` / `pendingParticipantEditAtom`)
1971
- - ✅ Focus ring visuals — Task 9
1972
- - ✅ Don't touch mouse paths — confirmed (only `EditableSpan` gains an optional prop)
1973
- - ✅ Tests: ring builder unit, keyboard hook unit, EditableSpan unit, E2E flow — Tasks 2, 4, 7, 13
1974
-
1975
- **Placeholder scan** — no "TBD", no "add appropriate X", no "similar to task N". One known duplication (`generateParticipantName` in `ParticipantLabel.tsx` and `ParticipantInsertControls.tsx`) is called out explicitly in Task 5.
1976
-
1977
- **Type consistency** — `focusedMessageAtom` stores `{ start, end }`; `MessageRingEntry` uses `labelStart` / `labelEnd`. The hook (Task 7) explicitly maps between them. `insertParticipantIntoDsl` return shape change is propagated through Task 3 and call sites are updated.
1978
-
1979
- **Known risk:** Task 2 depends on ANTLR fragment accessor names (`.block()`, `.braceBlock()`). If the actual generated accessors differ, Task 2 Step 5 instructs the engineer to read the grammar and adjust. The ring tests will catch this immediately.
1980
-
1981
- **Known risk:** Task 6 requires `blockContext` and `insertIndex` to be available in `Interaction.tsx`. `GapHandleZone` already receives these, so they are computed in `Block.tsx` — the engineer may need to thread them through `Statement.tsx`. This is a mechanical prop drill.
1982
-
1983
- ---
1984
-
1985
- ## Execution Handoff
1986
-
1987
- **Plan complete and saved to `docs/superpowers/plans/2026-04-15-keyboard-editing-on-diagram.md`. Two execution options:**
1988
-
1989
- 1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
1990
- 2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
1991
-
1992
- **Which approach?**