diff --git a/docs/superpowers/plans/2026-06-13-editor-landing-page.md b/docs/superpowers/plans/2026-06-13-editor-landing-page.md new file mode 100644 index 00000000..a81bd2c0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-editor-landing-page.md @@ -0,0 +1,555 @@ +# Editor as Landing Page — Implementation Plan + +> **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. + +**Goal:** Make bare `/` land in the editor (resume last-opened diagram, else sample), demoting the hub (HomeView) to an explicit `?view=diagrams` address, and add pre/post telemetry to validate the change. + +**Architecture:** Invert one routing condition — `isHomeMode` becomes opt-in via a new `?view=diagrams` search param instead of being the default for bare `/`. The existing `useBootItem` boot chain (share → `?code=` → `?id=` → last-code → sample) already produces the resume-or-sample landing, so no resolver work is needed; we just stop skipping it on bare `/`. Three telemetry events ride the existing `track()` helper. + +**Tech Stack:** React 19, TanStack Router (search-param routing), Zustand stores, Vitest + Testing Library (unit), Playwright (e2e). Package manager: Yarn for unit/build, pnpm for e2e. + +Spec: `docs/superpowers/specs/2026-06-13-editor-landing-design.md`. + +**Working directory for all commands:** `web/` (the rewrite app). Run `cd web` first. + +--- + +## File Structure + +| File | Responsibility | Change | +|------|----------------|--------| +| `web/src/app/router.tsx` | Route + search-param schema | Add `view` to `validateSearch` | +| `web/src/app/AppRoot.tsx` | App shell, home/editor gating, navigation, telemetry | Flip `isHomeMode` to opt-in; `goHome` → `?view=diagrams`; wire 3 events | +| `web/src/hooks/useBootItem.ts` | Boot item resolution | Add optional `onResolved(kind)` callback so AppRoot can fire `landed_in_editor` with the resolved `bootKind` | +| `web/src/app/AppRoot.test.tsx` | AppRoot unit tests | Re-ground 5 home-view tests `/` → `/?view=diagrams`; add editor-on-`/` + telemetry tests | +| `web/src/hooks/useBootItem.test.tsx` | Boot hook tests | Add `onResolved` callback test | +| `web/e2e/helpers/editor.ts` | E2E seed helper | Simplify `seedAndOpen` comment; verify still green (already tolerant) | + +--- + +## Task 1: Router — add `?view` search param + +**Files:** +- Modify: `web/src/app/router.tsx:8-15` (the `validateSearch` object) + +- [ ] **Step 1: Write the failing test** + +Create `web/src/app/router.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { indexRoute } from './router'; + +describe('indexRoute.validateSearch', () => { + const validate = indexRoute.options.validateSearch as (s: Record) => Record; + + it('parses view=diagrams into the search shape', () => { + expect(validate({ view: 'diagrams' }).view).toBe('diagrams'); + }); + + it('leaves view undefined when absent (bare /)', () => { + expect(validate({}).view).toBeUndefined(); + }); + + it('still parses the existing id param', () => { + expect(validate({ id: 'abc' }).id).toBe('abc'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && yarn vitest run src/app/router.test.tsx` +Expected: FAIL — `validate({ view: 'diagrams' }).view` is `undefined` (router does not yet read `view`). + +- [ ] **Step 3: Add the `view` param to validateSearch** + +In `web/src/app/router.tsx`, add the `view` line to the returned object (after `id`): + +```tsx + validateSearch: (s: Record) => ({ + id: s.id as string | undefined, + view: s.view as string | undefined, + 'share-token': s['share-token'] as string | undefined, + embed: s.embed !== undefined ? true : undefined, + code: s.code as string | undefined, + title: s.title as string | undefined, + stickyOffset: s.stickyOffset !== undefined ? Number(s.stickyOffset) : undefined, + }), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && yarn vitest run src/app/router.test.tsx` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add web/src/app/router.tsx web/src/app/router.test.tsx +git commit -m "feat(web/router): add ?view search param (hub address for editor-landing change)" +``` + +--- + +## Task 2: AppRoot — flip `isHomeMode` to opt-in, redirect bare `/` to the editor + +**Files:** +- Modify: `web/src/app/AppRoot.tsx:352-358` (read `view`, redefine `isHomeMode`) +- Modify: `web/src/app/AppRoot.tsx:667-681` (`goHome` → `?view=diagrams`) +- Modify: `web/src/app/AppRoot.test.tsx` — re-ground 5 home-view tests + add an editor-on-`/` test + +**Context:** Today `isHomeMode = !idParam && !shareToken && !runtime.embedCode && !isEmbed` (`AppRoot.tsx:358`) — bare `/` is the hub. We make the hub opt-in: it requires `view === 'diagrams'`. Bare `/` then falls through to `useBootItem` (the boot is skipped only when `isHomeMode || embedByValue`, `AppRoot.tsx:383`), which resumes last-code or seeds the sample. + +- [ ] **Step 1: Write the failing test — bare `/` renders the editor** + +In `web/src/app/AppRoot.test.tsx`, add inside `describe('AppRoot', …)` (near the existing "renders editor and preview regions" test): + +```tsx + it("bare '/' lands in the editor, not the hub", async () => { + window.history.replaceState({}, '', '/'); + render(); + expect(await screen.findByTestId('editor-region')).toBeInTheDocument(); + expect(screen.queryByTestId('home-view')).toBeNull(); + }); + + it("'?view=diagrams' renders the hub", async () => { + window.history.replaceState({}, '', '/?view=diagrams'); + render(); + expect(await screen.findByTestId('home-view')).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run to verify the new tests fail** + +Run: `cd web && yarn vitest run src/app/AppRoot.test.tsx -t "lands in the editor"` +Expected: FAIL — bare `/` still renders `home-view` (no `editor-region`), so `findByTestId('editor-region')` times out. + +- [ ] **Step 3: Redefine `isHomeMode` as opt-in** + +In `web/src/app/AppRoot.tsx`, replace the `isHomeMode` definition (around line 352-358). Add `viewParam` next to `idParam`: + +```tsx + const idParam = search.id ?? null; + const viewParam = search.view ?? null; + const shareToken = search['share-token'] ?? null; + + // Editor-as-landing (2026-06-13): the hub is now OPT-IN via ?view=diagrams. + // Bare "/" falls through to useBootItem (resume last-code, else sample) — the + // legacy landing behavior. id/share-token/embed still take precedence: a deep + // link with both ?view=diagrams and ?id= opens the diagram, not the hub. + const isHomeMode = + viewParam === 'diagrams' && !idParam && !shareToken && !runtime.embedCode && !isEmbed; +``` + +- [ ] **Step 4: Point `goHome` at `?view=diagrams`** + +Replace `goHome` (around line 667-681). It must SET `view: 'diagrams'` and clear the editor params: + +```tsx + // Editor-as-landing: return to the hub by setting ?view=diagrams (and clearing + // the editor params). Previously this cleared everything to bare "/", which now + // lands in the editor. + function goHome() { + void navigate({ + to: '/', + search: (prev) => ({ + ...prev, + view: 'diagrams', + id: undefined, + 'share-token': undefined, + embed: undefined, + code: undefined, + title: undefined, + stickyOffset: undefined, + }), + }); + } +``` + +- [ ] **Step 5: Re-ground the 5 home-view unit tests** + +In `web/src/app/AppRoot.test.tsx`, these 5 tests navigate to bare `/` expecting the hub. Change each `window.history.replaceState({}, '', '/')` that is immediately followed by a `home-view`/`home-grid`/`lib-export-all`/`lib-import-input` assertion to `'/?view=diagrams'`. The lines (before your edits shift them) are: + +- ~311 (import-failed dialog on home) → `'/?view=diagrams'` +- ~326 (ask-to-import on home) → `'/?view=diagrams'` +- ~901 (`'exportItems' uses legacy category 'fn'`) → `'/?view=diagrams'` +- ~921 (`'itemsImported' uses legacy category 'fn'`) → `'/?view=diagrams'` +- ~955 (`'itemsImported' label is the newly-added count`) → `'/?view=diagrams'` + +Leave the `afterEach` reset at line ~200 (`replaceState({}, '', '/')`) and the `beforeEach` default `'/?id=t-boot'` (line ~160) UNCHANGED — the default editor URL still works because `id` takes precedence. + +Verify you caught them all: + +```bash +cd web && grep -n "replaceState({}, '', '/')" src/app/AppRoot.test.tsx +``` + +Expected after edits: only the `afterEach` (line ~200) and the harness helper at line ~53 (`qs ? ... : '/'`) remain on bare `/`. Every test that then asserts `home-view`/`home-grid`/`lib-*` must be on `/?view=diagrams`. + +- [ ] **Step 6: Run the full AppRoot suite** + +Run: `cd web && yarn vitest run src/app/AppRoot.test.tsx` +Expected: PASS (all tests, including the 2 new gating tests and the 5 re-grounded ones). + +- [ ] **Step 7: Commit** + +```bash +git add web/src/app/AppRoot.tsx web/src/app/AppRoot.test.tsx +git commit -m "feat(web/hub)!: editor is the landing page — hub moves to ?view=diagrams; bare / resumes last diagram or seeds sample via existing boot chain" +``` + +--- + +## Task 3: Telemetry — `landed_in_editor` with `bootKind` + +**Files:** +- Modify: `web/src/hooks/useBootItem.ts` (add `onResolved` callback to `BootDeps` + call it in the hook) +- Modify: `web/src/hooks/useBootItem.test.tsx` (test the callback) +- Modify: `web/src/app/AppRoot.tsx:369-383` (pass `onResolved` that fires the event) + +**Context:** `useBootItem` resolves a `BootResult` with a `.kind` (`'shared' | 'item' | 'code' | 'lastcode' | 'share-error' | 'new'`) but only returns `{ shareError, clearShareError }`. We surface the resolved kind via an optional `onResolved(kind)` callback so AppRoot can fire `landed_in_editor` with `bootKind`. The callback is the minimal seam — the pure `resolveBootItem` stays untouched. + +- [ ] **Step 1: Write the failing test for `onResolved`** + +In `web/src/hooks/useBootItem.test.tsx`, add a new `describe` block (the hook needs a React renderer — use `@testing-library/react`'s `renderHook`): + +```tsx +import { renderHook, waitFor } from '@testing-library/react'; +import { useBootItem } from './useBootItem'; + +describe('useBootItem onResolved callback', () => { + it('calls onResolved with the resolved kind once auth is ready', async () => { + const onResolved = vi.fn(); + const deps = { + idParam: null, + shareToken: null, + codeParam: null, + codeTitle: null, + preserveLastCode: false, + getItem: vi.fn(), + getSharedItem: vi.fn(), + getLastCode: vi.fn(), + onResolved, + }; + renderHook(() => useBootItem(deps, true, false)); + await waitFor(() => expect(onResolved).toHaveBeenCalledWith('new')); + }); + + it('does not call onResolved when skipped (embed-by-value)', async () => { + const onResolved = vi.fn(); + const deps = { + idParam: null, shareToken: null, codeParam: null, codeTitle: null, + preserveLastCode: false, + getItem: vi.fn(), getSharedItem: vi.fn(), getLastCode: vi.fn(), + onResolved, + }; + renderHook(() => useBootItem(deps, true, true)); // skip=true + // Give any pending microtasks a chance, then assert it never fired. + await new Promise((r) => setTimeout(r, 0)); + expect(onResolved).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd web && yarn vitest run src/hooks/useBootItem.test.tsx -t "onResolved"` +Expected: FAIL — `onResolved` is not part of `BootDeps` (TS error) and never called. + +- [ ] **Step 3: Add `onResolved` to `BootDeps` and call it in the hook** + +In `web/src/hooks/useBootItem.ts`, add to the `BootDeps` interface (after `getLastCode`): + +```tsx + getLastCode: () => Promise; + /** + * Editor-as-landing telemetry seam: fired once with the resolved boot kind after + * resolution completes (NOT on skip). AppRoot uses it to emit `landed_in_editor` + * with bootKind. Optional so the pure resolver and existing callers are unaffected. + */ + onResolved?: (kind: BootResult['kind']) => void; +``` + +In the hook's `.then((result) => { … })` block, call `onResolved` after the switch, before the closing brace of `.then`: + +```tsx + resolveBootItem(deps).then((result) => { + switch (result.kind) { + case 'shared': + case 'item': + case 'code': + case 'lastcode': + loadItem(result.item); + break; + case 'share-error': + setShareError(true); + break; + case 'new': + newItem(); + break; + } + deps.onResolved?.(result.kind); + }).catch(() => { + newItem(); + }); +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd web && yarn vitest run src/hooks/useBootItem.test.tsx` +Expected: PASS (existing resolver tests + 2 new onResolved tests). + +- [ ] **Step 5: Wire `landed_in_editor` in AppRoot** + +In `web/src/app/AppRoot.tsx`, in the `useBootItem({ … })` deps object (around line 369-382), add an `onResolved` handler after `getLastCode`: + +```tsx + getLastCode: () => localStore.get(LS_KEYS.code, null), + // Editor-as-landing telemetry: one event per editor boot, tagged with how the + // diagram was resolved. Skipped on the hub (boot is skipped when isHomeMode) and + // on embed-by-value — so this fires only when the editor is the landing surface. + onResolved: (bootKind) => + track('landed_in_editor', { category: 'navigation', label: bootKind }), + // Hub: skip boot when on home — we don't want newItem() seeding a blank diagram there. + }, authReady, isHomeMode || embedByValue); +``` + +- [ ] **Step 6: Add an AppRoot telemetry test** + +In `web/src/app/AppRoot.test.tsx`, add (the file already has `lastEnvelope(eventName)` + `trackMock`): + +```tsx + it("fires 'landed_in_editor' with bootKind on a bare '/' editor landing", async () => { + window.history.replaceState({}, '', '/'); + render(); + await screen.findByTestId('editor-region'); + const env = await waitFor(() => { + const e = lastEnvelope('landed_in_editor'); + expect(e).toBeDefined(); + return e!; + }); + // getItem is mocked to reject in this suite → boot resolves kind 'new'. + expect(env.label).toBe('new'); + expect(env.category).toBe('navigation'); + }); +``` + +- [ ] **Step 7: Run the AppRoot + hook suites** + +Run: `cd web && yarn vitest run src/app/AppRoot.test.tsx src/hooks/useBootItem.test.tsx` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/hooks/useBootItem.ts web/src/hooks/useBootItem.test.tsx web/src/app/AppRoot.tsx web/src/app/AppRoot.test.tsx +git commit -m "feat(web/telemetry): emit landed_in_editor{bootKind} via useBootItem onResolved seam" +``` + +--- + +## Task 4: Telemetry — `hub_opened` and `first_edit` + +**Files:** +- Modify: `web/src/app/AppRoot.tsx` — `goHome` (`hub_opened` source breadcrumb); a mount effect (`hub_opened` source landing-param); a `setDsl` wrapper (`first_edit`) +- Modify: `web/src/app/AppRoot.test.tsx` — 2 tests + +**Context:** +- `hub_opened` fires when the hub becomes the active surface, with `source`: `'breadcrumb'` (user clicked "Your diagrams" → `goHome`) or `'landing-param'` (arrived directly at `?view=diagrams`). +- `first_edit` fires once per AppRoot mount on the first user-initiated DSL change. `setDsl` is wired to `CodeEditor onChange` at `AppRoot.tsx:1162` and `1252`. We wrap it. + +- [ ] **Step 1: Write the failing tests** + +In `web/src/app/AppRoot.test.tsx`: + +```tsx + it("fires 'hub_opened' with source 'landing-param' when arriving at ?view=diagrams", async () => { + window.history.replaceState({}, '', '/?view=diagrams'); + render(); + await screen.findByTestId('home-view'); + const env = await waitFor(() => { + const e = lastEnvelope('hub_opened'); + expect(e).toBeDefined(); + return e!; + }); + expect(env.label).toBe('landing-param'); + }); + + it("fires 'first_edit' once on the first DSL change", async () => { + window.history.replaceState({}, '', '/?id=t-boot'); + render(); + await screen.findByTestId('editor-region'); + const editor = screen.getByTestId('dsl-editor'); + const textbox = editor.querySelector('[contenteditable], textarea, input') ?? editor; + await act(async () => { await userEvent.click(textbox); await userEvent.type(textbox, 'A.b'); }); + await waitFor(() => expect(lastEnvelope('first_edit')).toBeDefined()); + // Count: exactly one first_edit despite multiple keystrokes. + const count = trackMock.mock.calls.filter( + ([payload]) => (payload as { event?: string }).event === 'first_edit', + ).length; + expect(count).toBe(1); + }); +``` + +NOTE: If the `dsl-editor`'s CodeMirror surface does not accept `userEvent.type` cleanly in jsdom, drive the change through the store action the editor calls instead: replace the type block with a direct invocation of the wrapped handler by simulating two `setDsl` calls. Inspect how existing tests in this file edit the DSL (search for `setDsl`, `dsl-editor`, or `fireEvent` usage) and mirror that exact mechanism — do not invent a new one. + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd web && yarn vitest run src/app/AppRoot.test.tsx -t "hub_opened|first_edit"` +Expected: FAIL — neither event is emitted yet. + +- [ ] **Step 3: Add the `first_edit` wrapper** + +In `web/src/app/AppRoot.tsx`, near the other handlers (after `goHome`), add a once-per-mount ref and a wrapped DSL handler. First, ensure `useRef` is imported (it is used elsewhere — confirm `import { … useRef … } from 'react'` at the top). Add: + +```tsx + const firstEditFired = useRef(false); + function handleDslChange(next: string) { + if (!firstEditFired.current) { + firstEditFired.current = true; + track('first_edit', { category: 'fn' }); + } + setDsl(next); + } +``` + +Then replace the two `onChange={setDsl}` usages on the DSL `CodeEditor` (lines ~1162 and ~1252) with `onChange={handleDslChange}`. Verify there are exactly two: + +```bash +cd web && grep -n "onChange={setDsl}\|onCodeChange={setDsl}" src/app/AppRoot.tsx +``` + +Match the prop name actually present (`onChange` at 1162, `onCodeChange` at 1252) and swap each to `handleDslChange`. CSS/HTML editors use different handlers (`handleSetCss`) — do NOT touch those; `first_edit` tracks DSL edits only. + +- [ ] **Step 4: Add the `hub_opened` events** + +For the breadcrumb source, add the track call inside `goHome` (first line of the function body): + +```tsx + function goHome() { + track('hub_opened', { category: 'navigation', label: 'breadcrumb' }); + void navigate({ /* …unchanged… */ }); + } +``` + +For the landing-param source, add an effect that fires once when `isHomeMode` is true on mount. Place it near other effects, after `isHomeMode` is defined: + +```tsx + // Editor-as-landing telemetry: arriving directly at ?view=diagrams (not via the + // breadcrumb) — fire once. goHome covers the breadcrumb path with its own source. + const hubLandingFired = useRef(false); + useEffect(() => { + if (isHomeMode && !hubLandingFired.current) { + hubLandingFired.current = true; + track('hub_opened', { category: 'navigation', label: 'landing-param' }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isHomeMode]); +``` + +- [ ] **Step 5: Run to verify they pass** + +Run: `cd web && yarn vitest run src/app/AppRoot.test.tsx -t "hub_opened|first_edit"` +Expected: PASS. + +- [ ] **Step 6: Run the full AppRoot suite (guard against double-count regressions)** + +Run: `cd web && yarn vitest run src/app/AppRoot.test.tsx` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/app/AppRoot.tsx web/src/app/AppRoot.test.tsx +git commit -m "feat(web/telemetry): emit hub_opened{source} and first_edit (once/mount) for editor-landing pre/post validation" +``` + +--- + +## Task 5: E2E helper re-ground + full green + +**Files:** +- Modify: `web/e2e/helpers/editor.ts:43-63` (`seedAndOpen` — update stale comment; the click-through is already tolerant) + +**Context:** `seedAndOpen` goes to `/`, and IF a `home-view` is visible clicks through to the editor, ELSE waits for editor content. After this change bare `/` lands in the editor directly, so the `home-view` branch is dead but harmless. Keep the tolerant branch (it documents intent and survives a revert), fix the comment. + +- [ ] **Step 1: Update the `seedAndOpen` doc comment** + +In `web/e2e/helpers/editor.ts`, replace the "Hub routing (PR #800/#801)" comment block (lines ~43-46) with: + +```ts + * Editor-as-landing (2026-06-13): "/" now lands directly in the editor (resume + * last diagram, else sample). The home-view click-through below is kept as a + * tolerant fallback — it no-ops on the current default but survives a revert and + * still works for any flow that opens the hub first (?view=diagrams). +``` + +- [ ] **Step 2: List the e2e specs to confirm collection is unaffected** + +Run: `cd web && pnpm exec playwright test --list 2>&1 | tail -20` +Expected: the spec list prints without collection errors (no network needed). If `pnpm exec playwright` is unavailable, use `npx playwright test --list`. + +- [ ] **Step 3: Run the full unit suite (whole-app regression gate)** + +Run: `cd web && yarn test` +Expected: PASS — all unit + integration tests green. Pay attention to `AppRoot.editorWiring.test.tsx` (it boots the editor and may have assumed bare `/`); if any test there navigated to bare `/` expecting the hub, re-ground it to `/?view=diagrams` the same way as Task 2 Step 5, then re-run. + +- [ ] **Step 4: Typecheck** + +Run: `cd web && yarn typecheck` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add web/e2e/helpers/editor.ts +git commit -m "test(web/e2e): re-ground seedAndOpen comment for editor-as-landing; tolerant hub fallback retained" +``` + +--- + +## Task 6: Live verification (Playwright screenshot, real browser) + +**Context:** Per the project's acceptance rule (memory: "Final acceptance = Playwright + screenshots"), capture the real landing behavior against the local dev server. + +**Files:** none (verification only). + +- [ ] **Step 1: Confirm the dev server is up** + +Run: `curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/` +Expected: `200`. If not 200, start it: `cd web && yarn dev` (check CLAUDE.md — do not start a second server if one is already running). + +- [ ] **Step 2: Capture bare `/` (should be the editor)** + +Run: +```bash +cd /Users/pengxiao/workspaces/zenuml/web-sequence +agent-browser set viewport 1440 900 && agent-browser open http://localhost:3000/ && agent-browser wait --load networkidle && agent-browser wait 1500 && agent-browser screenshot tmp/editor-landing/bare-root.png +``` +Expected: editor chrome (DSL panel + preview), NOT the hub empty-state. + +- [ ] **Step 3: Capture `?view=diagrams` (should be the hub)** + +Run: +```bash +agent-browser open "http://localhost:3000/?view=diagrams" && agent-browser wait --load networkidle && agent-browser wait 1500 && agent-browser screenshot tmp/editor-landing/hub.png && agent-browser close +``` +Expected: HomeView library grid / empty-state. + +- [ ] **Step 4: Read both screenshots and confirm** + +Read `tmp/editor-landing/bare-root.png` and `tmp/editor-landing/hub.png`. Confirm bare `/` shows the editor and `?view=diagrams` shows the hub. If either is wrong, STOP and diagnose before claiming done. + +- [ ] **Step 5: Final report** + +Summarize: behavior change, telemetry events added, test counts (unit pass count), and attach the two screenshot paths. Do NOT commit screenshots (they live under `tmp/`, which is gitignored). + +--- + +## Self-Review notes (author) + +- **Spec coverage:** routing flip (Task 2) ✓; `?view=diagrams` hub (Tasks 1–2) ✓; resume-last/sample via existing boot (Task 2, no resolver change — confirmed `useBootItem.ts` already does this) ✓; `landed_in_editor` (Task 3) ✓; `hub_opened` (Task 4) ✓; `first_edit` as a NEW event (Task 4 — spec confirms `web/` has no edit tracking) ✓; test re-grounding (Tasks 2, 5) ✓; e2e helper (Task 5) ✓; live screenshot acceptance (Task 6) ✓. +- **Precedence rule** (`id`/`share-token`/`embed` beat `view`) is encoded in the `isHomeMode` conjunction (Task 2 Step 3) and asserted indirectly by the unchanged `beforeEach` default `/?id=t-boot` staying in the editor. +- **Naming consistency:** event names `landed_in_editor` / `hub_opened` / `first_edit`; `bootKind` label values match `BootResult['kind']`; handler `handleDslChange`; refs `firstEditFired` / `hubLandingFired` — used identically across tasks. +- **Known soft spot:** Task 4 Step 1's `first_edit` test drives CodeMirror via `userEvent.type`, which can be flaky in jsdom — the step includes an explicit fallback instruction to mirror the file's existing DSL-edit mechanism. diff --git a/docs/superpowers/plans/2026-06-14-report-a-bug.md b/docs/superpowers/plans/2026-06-14-report-a-bug.md new file mode 100644 index 00000000..1424a73d --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-report-a-bug.md @@ -0,0 +1,978 @@ +# Report a Bug — Implementation Plan + +> **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. + +**Goal:** Add a prominent, persistent "Report a bug" floating button that opens an in-app modal and produces a prefilled GitHub issue with auto-captured context — and bootstrap Storybook in the `web/` rewrite with this feature's components as its first stories. + +**Architecture:** A pure `buildIssueUrl` helper (no DOM/globals) turns user input + context into a GitHub `issues/new?title=&body=&labels=bug` URL with a length guard. `ReportBugModal` (Drafting Table `Dialog`) collects the description + a default-ON "include my diagram code (public)" toggle and shows exactly what will be attached. `ReportBugButton` is a fixed bottom-right FAB that owns the modal. `AppRoot` renders the FAB app-wide (hub + editor returns, not embed), feeding it the current DSL, app version, view, signed-in flag, and two Mixpanel events. + +**Tech Stack:** React 19, TypeScript, Vite 8, Tailwind 3 (Drafting Table tokens), Vitest + Testing Library, Radix (Dialog/Switch via `web/src/ui`), Storybook (current latest major, `@storybook/react-vite`), Playwright. Package manager for `web/` is **pnpm**. + +**Conventions:** +- All `pnpm` commands shown as `pnpm -C web …` (run from repo root; `-C web` sets the project dir). +- All `git` commands run from repo root; paths are `web/…`. +- Commit messages are one line. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `web/src/services/bugReport.ts` (create) | Pure `buildIssueUrl(input)` → GitHub URL. Body markdown + title derivation + URL length guard/truncation. No DOM, no globals. | +| `web/src/services/bugReport.test.ts` (create) | Unit tests for the builder. | +| `web/src/components/feedback/ReportBugModal.tsx` (create) | The modal: description field, DSL toggle (default ON), attached-summary, submit, contact-us fallback. Calls `buildIssueUrl`, opens the URL via an injectable `openUrl` (defaults to `window.open`). | +| `web/src/components/feedback/ReportBugModal.test.tsx` (create) | Component tests. | +| `web/src/components/feedback/ReportBugButton.tsx` (create) | Fixed bottom-right FAB; owns modal open state; fires `onOpen`/`onSubmitted`. | +| `web/src/components/feedback/ReportBugButton.test.tsx` (create) | Component test. | +| `web/src/app/AppRoot.tsx` (modify) | Import + render the FAB in the hub and editor returns; wire DSL/version/view/auth + telemetry. | +| `web/.storybook/main.ts` (create) | Storybook config (framework, stories glob, a11y addon). | +| `web/.storybook/preview.ts` (create) | Global CSS import + dark ink decorator. | +| `web/package.json` (modify) | Add `storybook`/`build-storybook` scripts + dev deps. | +| `web/src/components/feedback/ReportBugButton.stories.tsx` (create) | Button stories. | +| `web/src/components/feedback/ReportBugModal.stories.tsx` (create) | Modal state stories. | +| `web/e2e/report-bug.spec.ts` (create) | Playwright journey + screenshot. | + +--- + +## Task 1: `buildIssueUrl` pure helper + +**Files:** +- Create: `web/src/services/bugReport.ts` +- Test: `web/src/services/bugReport.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `web/src/services/bugReport.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { buildIssueUrl, type BuildIssueInput } from './bugReport'; + +const base: BuildIssueInput = { + description: 'Arrows render upside down', + includeDsl: false, + appVersion: '2026.6.7', + userAgent: 'Mozilla/5.0 (Test)', + view: 'editor', + signedIn: false, +}; + +function body(url: string): string { + return new URL(url).searchParams.get('body') ?? ''; +} +function title(url: string): string { + return new URL(url).searchParams.get('title') ?? ''; +} + +describe('buildIssueUrl', () => { + it('targets the repo new-issue endpoint with a bug label', () => { + const url = buildIssueUrl(base); + expect(url.startsWith('https://github.com/ZenUml/web-sequence/issues/new?')).toBe(true); + expect(new URL(url).searchParams.get('labels')).toBe('bug'); + }); + + it('puts the first non-empty line in the title', () => { + const url = buildIssueUrl({ ...base, description: ' \n Arrows broken \nmore detail' }); + expect(title(url)).toBe('Arrows broken'); + }); + + it('falls back to a generic title when description is blank', () => { + expect(title(buildIssueUrl({ ...base, description: ' \n ' }))).toBe('Bug report'); + }); + + it('includes environment lines and the description', () => { + const b = body(buildIssueUrl(base)); + expect(b).toContain('Arrows render upside down'); + expect(b).toContain('App version: 2026.6.7'); + expect(b).toContain('Browser: Mozilla/5.0 (Test)'); + expect(b).toContain('View: editor · Signed in: no'); + }); + + it('omits the DSL block when includeDsl is false', () => { + expect(body(buildIssueUrl({ ...base, includeDsl: false, dsl: 'A -> B: x' }))).not.toContain('
'); + }); + + it('includes the DSL block when includeDsl is true and dsl is present', () => { + const b = body(buildIssueUrl({ ...base, includeDsl: true, dsl: 'A -> B: hello' })); + expect(b).toContain('
Diagram DSL'); + expect(b).toContain('A -> B: hello'); + }); + + it('omits the DSL block when dsl is empty even if includeDsl is true', () => { + expect(body(buildIssueUrl({ ...base, includeDsl: true, dsl: ' ' }))).not.toContain('
'); + }); + + it('truncates an oversized DSL so the URL stays within budget', () => { + const url = buildIssueUrl({ ...base, includeDsl: true, dsl: 'X'.repeat(20000) }); + expect(url.length).toBeLessThanOrEqual(6000); + const b = body(url); + expect(b).toContain('truncated'); + expect(b).toContain('Arrows render upside down'); // description is never dropped + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm -C web test src/services/bugReport.test.ts` +Expected: FAIL — `Failed to resolve import './bugReport'` (module does not exist yet). + +- [ ] **Step 3: Write the implementation** + +Create `web/src/services/bugReport.ts`: + +```ts +// Pure builder for a prefilled GitHub "new issue" URL. No DOM, no globals — every +// input is passed in, so this is trivially unit-tested. The caller (ReportBugModal) +// supplies navigator.userAgent and the current editor DSL. + +const REPO = 'https://github.com/ZenUml/web-sequence'; + +// Conservative cap on total URL length. GitHub returns 414 (URI Too Long) on +// oversized issue prefills; ~8 KB is the practical ceiling, so 6 KB leaves headroom. +const URL_BUDGET = 6000; + +const TRUNCATION_MARKER = '\n… (truncated — please paste the rest)'; + +export interface BuildIssueInput { + description: string; + includeDsl: boolean; + dsl?: string; + appVersion: string; + userAgent: string; + view: string; + signedIn: boolean; +} + +function firstLine(description: string, max = 80): string { + const line = description + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0); + if (!line) return 'Bug report'; + return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line; +} + +function environmentBlock(input: BuildIssueInput): string { + return [ + '**Describe the bug**', + input.description.trim(), + '', + '**Environment**', + `- App version: ${input.appVersion}`, + `- Browser: ${input.userAgent}`, + `- View: ${input.view} · Signed in: ${input.signedIn ? 'yes' : 'no'}`, + ].join('\n'); +} + +function dslBlock(dsl: string): string { + return ['', '
Diagram DSL', '', '```', dsl, '```', '
'].join('\n'); +} + +function composeUrl(title: string, body: string): string { + const params = new URLSearchParams({ title, body, labels: 'bug' }); + return `${REPO}/issues/new?${params.toString()}`; +} + +// Largest prefix of `dsl` whose composed URL still fits URL_BUDGET, with the +// truncation marker appended when the full DSL doesn't fit. Binary search keeps +// this O(log n) and deterministic. +function fitDsl(title: string, baseBody: string, dsl: string): string { + const withDsl = (d: string) => `${baseBody}${dslBlock(d)}`; + if (composeUrl(title, withDsl(dsl)).length <= URL_BUDGET) return withDsl(dsl); + let lo = 0; + let hi = dsl.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + const candidate = dsl.slice(0, mid) + TRUNCATION_MARKER; + if (composeUrl(title, withDsl(candidate)).length <= URL_BUDGET) lo = mid; + else hi = mid - 1; + } + return withDsl(dsl.slice(0, lo) + TRUNCATION_MARKER); +} + +export function buildIssueUrl(input: BuildIssueInput): string { + const title = firstLine(input.description); + const baseBody = environmentBlock(input); + const dsl = input.dsl?.trim() ? input.dsl : ''; + if (!input.includeDsl || !dsl) return composeUrl(title, baseBody); + return composeUrl(title, fitDsl(title, baseBody, dsl)); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm -C web test src/services/bugReport.test.ts` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add web/src/services/bugReport.ts web/src/services/bugReport.test.ts +git commit -m "feat(web/feedback): pure buildIssueUrl helper for prefilled GitHub bug reports" +``` + +--- + +## Task 2: `ReportBugModal` component + +**Files:** +- Create: `web/src/components/feedback/ReportBugModal.tsx` +- Test: `web/src/components/feedback/ReportBugModal.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `web/src/components/feedback/ReportBugModal.test.tsx`: + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ReportBugModal } from './ReportBugModal'; + +const props = { + open: true, + onOpenChange: () => {}, + appVersion: '2026.6.7', + view: 'editor', + signedIn: false, +}; + +describe('ReportBugModal', () => { + it('does not render content when closed', () => { + render(); + expect(screen.queryByTestId('report-bug-modal')).toBeNull(); + }); + + it('disables submit until a description is entered', () => { + render(); + expect(screen.getByTestId('report-bug-submit')).toBeDisabled(); + fireEvent.change(screen.getByTestId('report-bug-description'), { + target: { value: 'RenderGlitch' }, + }); + expect(screen.getByTestId('report-bug-submit')).not.toBeDisabled(); + }); + + it('defaults the DSL toggle to ON when there is editor content', () => { + render(); + expect(screen.getByTestId('report-bug-include-dsl').getAttribute('data-state')).toBe('checked'); + }); + + it('hides the DSL toggle when there is no editor content', () => { + render(); + expect(screen.queryByTestId('report-bug-include-dsl')).toBeNull(); + }); + + it('opens a GitHub URL containing the description on submit', () => { + const openUrl = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('report-bug-description'), { + target: { value: 'RenderGlitch' }, + }); + fireEvent.click(screen.getByTestId('report-bug-submit')); + expect(openUrl).toHaveBeenCalledTimes(1); + const url = openUrl.mock.calls[0][0] as string; + expect(url).toContain('github.com/ZenUml/web-sequence/issues/new'); + expect(url).toContain('RenderGlitch'); + }); + + it('offers a contact-us fallback for users without GitHub', () => { + render(); + const link = screen.getByRole('link', { name: /contact us/i }); + expect(link.getAttribute('href')).toBe('https://zenuml.com/docs/about/contact-us'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm -C web test src/components/feedback/ReportBugModal.test.tsx` +Expected: FAIL — cannot resolve `./ReportBugModal`. + +- [ ] **Step 3: Write the implementation** + +Create `web/src/components/feedback/ReportBugModal.tsx`: + +```tsx +import { useEffect, useState } from 'react'; +import { Dialog, DialogContent, Button, Textarea, Switch } from '../../ui'; +import { buildIssueUrl } from '../../services/bugReport'; + +// Verified contact page (same URL the Help modal links to). Used as the fallback +// for reporters without a GitHub account. +const CONTACT_URL = 'https://zenuml.com/docs/about/contact-us'; + +export interface ReportBugModalProps { + open: boolean; + onOpenChange(open: boolean): void; + // Current editor source. Empty/whitespace => no DSL option (hub, or empty doc). + dsl?: string; + appVersion: string; + view: string; + signedIn: boolean; + // Side-effect injected for testability; defaults to opening a new tab. + openUrl?(url: string): void; + onSubmitted?(meta: { includedDsl: boolean }): void; +} + +export function ReportBugModal({ + open, + onOpenChange, + dsl, + appVersion, + view, + signedIn, + openUrl = (url) => window.open(url, '_blank', 'noopener,noreferrer'), + onSubmitted, +}: ReportBugModalProps) { + const hasDsl = !!(dsl && dsl.trim()); + const [description, setDescription] = useState(''); + const [includeDsl, setIncludeDsl] = useState(true); + + // Reset the form whenever the modal closes so a reopen starts clean. + useEffect(() => { + if (!open) { + setDescription(''); + setIncludeDsl(true); + } + }, [open]); + + const canSubmit = description.trim().length > 0; + + const handleSubmit = () => { + if (!canSubmit) return; + const included = hasDsl && includeDsl; + const url = buildIssueUrl({ + description, + includeDsl: included, + dsl, + appVersion, + userAgent: navigator.userAgent, + view, + signedIn, + }); + openUrl(url); + onSubmitted?.({ includedDsl: included }); + onOpenChange(false); + }; + + return ( + + +
+