Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2024-06-18 - Accessible Collapsible Table Sections

**Learning:** When implementing collapsible table sections (`<tbody>`), conditionally unmounting the content in React causes its `id` to be removed from the DOM. This breaks the `aria-controls` attribute on the toggle button, which expects the controlled element to be present.
**Action:** Always render the collapsible table content into the DOM and toggle its visibility using a CSS class (like `.hidden`) instead of conditional rendering. This keeps the `id` available for `aria-controls` and provides a nice performance boost (avoiding DOM remounts). Extracting heavy grouping operations into `useMemo` hooks is also critical to prevent recalculations when these toggles update local state.
64 changes: 34 additions & 30 deletions apps/app/app/components/ActorTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { ChevronRight, CircleDot, ListOrdered, User } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -107,7 +107,7 @@ export function ActorTable({
actors: ActorSummary[];
usecaseCountByActor: Record<string, number>;
}) {
const groups = groupByType(actors);
const groups = useMemo(() => groupByType(actors), [actors]);
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());

function toggle(type: string) {
Expand Down Expand Up @@ -149,40 +149,44 @@ export function ActorTable({
groups.map(([type, items]) => {
const isOpen = !collapsed.has(type);
return (
<tbody key={type}>
<tr className="border-t border-border bg-muted/30">
<td colSpan={COLUMN_COUNT} className="py-1.5 pr-3 pl-4">
<button
type="button"
onClick={() => toggle(type)}
aria-expanded={isOpen}
className="inline-flex items-center gap-2 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-90"
)}
/>
<span className="font-medium text-foreground">
{actorTypeLabel(type)}
</span>
<span className="text-xs text-muted-foreground">
{items.length}
</span>
</button>
</td>
</tr>
{isOpen &&
items.map((actor) => (
<Fragment key={type}>
<tbody key={`header-${type}`}>
<tr className="border-t border-border bg-muted/30">
<td colSpan={COLUMN_COUNT} className="py-1.5 pr-3 pl-4">
<button
type="button"
onClick={() => toggle(type)}
aria-expanded={isOpen}
aria-controls={`group-${type}`}
className="inline-flex items-center gap-2 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-90"
)}
/>
<span className="font-medium text-foreground">
{actorTypeLabel(type)}
</span>
<span className="text-xs text-muted-foreground">
{items.length}
</span>
</button>
</td>
</tr>
</tbody>
<tbody id={`group-${type}`} className={cn(!isOpen && "hidden")}>
{items.map((actor) => (
<ActorRow
key={actor.id}
actor={actor}
usecaseCount={usecaseCountByActor[actor.name] ?? 0}
/>
))}
</tbody>
</tbody>
</Fragment>
);
})
)}
Expand Down
60 changes: 32 additions & 28 deletions apps/app/app/components/UsecaseTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { Fragment, useMemo, useState } from "react";
import Link from "next/link";
import {
ChevronRight,
Expand Down Expand Up @@ -144,7 +144,7 @@ export function UsecaseTable({
usecases: UsecaseSummary[];
projectKey: string;
}) {
const groups = groupByLevel(usecases);
const groups = useMemo(() => groupByLevel(usecases), [usecases]);
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());

function toggle(level: string) {
Expand Down Expand Up @@ -194,38 +194,42 @@ export function UsecaseTable({
groups.map(([level, items]) => {
const isOpen = !collapsed.has(level);
return (
<tbody key={level}>
<tr className="border-t border-border bg-muted/30">
<td colSpan={COLUMN_COUNT} className="py-1.5 pr-3 pl-4">
<button
type="button"
onClick={() => toggle(level)}
aria-expanded={isOpen}
className="inline-flex items-center gap-2 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-90"
)}
/>
<LevelTag level={level} />
<span className="text-xs text-muted-foreground">
{items.length}
</span>
</button>
</td>
</tr>
{isOpen &&
items.map((usecase) => (
<Fragment key={level}>
<tbody key={`header-${level}`}>
<tr className="border-t border-border bg-muted/30">
<td colSpan={COLUMN_COUNT} className="py-1.5 pr-3 pl-4">
<button
type="button"
onClick={() => toggle(level)}
aria-expanded={isOpen}
aria-controls={`group-${level}`}
className="inline-flex items-center gap-2 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-90"
)}
/>
<LevelTag level={level} />
<span className="text-xs text-muted-foreground">
{items.length}
</span>
</button>
</td>
</tr>
</tbody>
<tbody id={`group-${level}`} className={cn(!isOpen && "hidden")}>
{items.map((usecase) => (
<UsecaseRow
key={usecase.key}
usecase={usecase}
projectKey={projectKey}
/>
))}
</tbody>
</tbody>
</Fragment>
);
})
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/tests/e2e-cli/UC-033.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("UC-033 CLI - Learn how to use vspec", () => {
);
expect(result.stdout).toContain("vspec usecase add-stakeholder");
expect(result.stdout).toContain("Existing use case edits");
expect(result.stdout).toContain("`vspec step add` appends");
expect(result.stdout).toContain("vspec step add --at <n>");
expect(result.stdout).toContain(
"vspec scenario add POCKET-001 --type EXTENSION --at 2a"
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ got connected.
- `json` -- the structured `VerifyResult` with a top-level `status`.
- `agent` -- the agent envelope whose `data.status` and `data.drift` an agent
can branch on.
The exit code follows the verdict exactly as `vspec verify` already does
(`0` pass, `7` unlinked steps, `1` otherwise).
The exit code follows the verdict exactly as `vspec verify` already does
(`0` pass, `7` unlinked steps, `1` otherwise).
3. No duplicated verdict logic: there is exactly **one** `runVerify` definition
under `apps/cli/src`, and **every** `apps/cli/src` file that references it
(other than the file that defines it) imports the shared producer rather than
Expand All @@ -76,7 +76,7 @@ The set of accepted formats is the whitelist in `verifyFormat`
(`apps/cli/src/commands/verify.ts`): `human`, `json`, `agent`. The set of
`apps/cli/src` files that reference the verdict producer is enumerated from
source with `grep -rln 'runVerify' apps/cli/src`; exactly one of them may
*define* `runVerify`, and the gate loops over the rest to confirm each imports
_define_ `runVerify`, and the gate loops over the rest to confirm each imports
the shared definition rather than re-declaring it.

## Verification
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ So the human view silently diverges from the agent / json / markdown views.
- its **extension point** (e.g. `2a`),
- its **condition** (e.g. `Title is empty`), and
- its **outcome** when present (one of `FAILURE` / `PARTIAL` / `SUCCESS`).
Recovery **steps** continue to render, exactly as before, when present. No
extension is silently dropped.
Recovery **steps** continue to render, exactly as before, when present. No
extension is silently dropped.
3. Format parity holds: for **every** output format `usecase show` accepts
(`human`, `json`, `agent`), the extension data is present in the rendered
output. `json` and `agent` already serialize the raw scenarios; `human` now
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ no pass/fail summary, no findings. The agent could not rely on `verify` and fell
back to reading `usecase show` ("The spec reads correctly") to validate
correctness by eye, making the verify step dead weight before push.

Goal 43 closed the *routing* half of this symptom (verify no longer dead-ends on
a banner). DF-001 is the *substance* half: even when verify runs, it only checks
Goal 43 closed the _routing_ half of this symptom (verify no longer dead-ends on
a banner). DF-001 is the _substance_ half: even when verify runs, it only checks
link drift, so a structurally broken spec (missing actor, empty scenario,
dangling extension point, missing Cockburn field) still verifies clean. This
goal makes verify actually inspect the spec and emit a per-check verdict an agent
Expand Down Expand Up @@ -96,7 +96,7 @@ stays single-source, and `usecase.ts` still routes the `verify` action into it).

The set of `apps/cli/src` files that reference the spec-check producer is
enumerated from source with `grep -rln 'runSpecChecks' apps/cli/src`; exactly one
of them may *define* `runSpecChecks` (enumerated with
of them may _define_ `runSpecChecks` (enumerated with
`grep -rln 'function runSpecChecks' apps/cli/src`), and the gate loops over the
rest to confirm each imports the shared definition rather than re-declaring it.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Two things must become true:
1. The guide must stop shipping self-contradicting create examples: every
`vspec usecase create` example command the guide emits must carry a title the
validator accepts, and none of those create examples may pass `--force`. A
self-teaching example must demonstrate a title that *works*, not one that has
self-teaching example must demonstrate a title that _works_, not one that has
to be forced.
2. When `usecase create` genuinely rejects a title, the `TITLE_NOT_VERB_PHRASE`
response must teach the fix end to end: it must surface at least one concrete
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,3 @@ do next (`docs/06-api-contract.md`).
`unlinked_steps`, `failing_tests`) beyond folding structural gaps into the
shared `status` / `exit_code` rollup.
- No prior goal gate may be weakened to pass this goal.

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ When an agent runs `vspec usecase verify <id>` (or the equivalent `vspec verify
clean, the command must tell the agent what to do about it -- not just that
something failed. Goal 43 already made `verify` route into `runVerify` and emit a
stable `status` in every format; that closed the "opaque banner" symptom for the
*pass* case. But the finding behind this goal is the *fail* case: when verify
_pass_ case. But the finding behind this goal is the _fail_ case: when verify
reports drift the agent still has to reverse-engineer the remedy by hand. A
verification command whose failure output cannot be acted on programmatically
forced the dogfood agent to abandon `verify` and validate by eye.
Expand Down Expand Up @@ -93,7 +93,7 @@ stays single-source, and `usecase.ts` still routes the `verify` action into it).

The set of `apps/cli/src` files that reference the remediation producer is
enumerated from source with `grep -rln 'suggestVerifyActions' apps/cli/src`;
exactly one of them may *define* `suggestVerifyActions` (enumerated with
exactly one of them may _define_ `suggestVerifyActions` (enumerated with
`grep -rln 'function suggestVerifyActions' apps/cli/src`), and the gate loops over
the rest to confirm each imports the shared definition rather than re-declaring
it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ and the use cases are still at `DRAFT`.
## Source finding

A task asked the agent to act on what `verify` reported. For POCKET-001..005,
`verify` flagged *every* step as `unlinked` and suggested adding `implements`
`verify` flagged _every_ step as `unlinked` and suggested adding `implements`
refs — while `verify` itself reported `Tests not run`, the repo contained no
implementation code, and all five use cases were still at `DRAFT`. Meanwhile
every real spec gate was green (`actors_registered`, `scenario_completeness`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ This resolves
purpose remains unclear"). The agent could not determine what verify checks or
whether its spec passed.

Goals 43/54/55 made the *verdict* rich and actionable but operated on
Goals 43/54/55 made the _verdict_ rich and actionable but operated on
`runUsecase`/`runVerify` internals; they were verified through direct calls and a
frozen route snapshot, neither of which enumerates the `runUsecase` action set
against the live route table. So the missing `usecase verify` route slipped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A closed whitelist of English verbs is a correctness liability: it silently
degrades valid specs, it is impossible to keep complete, and it cuts against the
language-aware / Korean-first design principle. The dogfood agent could not
recover from the rejection on its own — the message ("Use case title should be a
verb phrase") never named *which* word it disliked, so the agent built the wrong
verb phrase") never named _which_ word it disliked, so the agent built the wrong
mental model ("the validator only accepts a leading subject like `User`") and
fell back to `--force`.

Expand All @@ -25,7 +25,7 @@ Two things must become true:
the fix is a more permissive heuristic or a meaningfully broadened verb set,
the dogfood-discovered title and a representative spread of common finite
verbs the old closed set omitted must all be accepted.
2. When a title *is* genuinely rejected, the `TITLE_NOT_VERB_PHRASE` response
2. When a title _is_ genuinely rejected, the `TITLE_NOT_VERB_PHRASE` response
must name the offending word so an agent can reason about the fix rather than
guess. `--force` stays a real escape hatch; it must not be the only way past a
legitimate verb phrase.
Expand Down Expand Up @@ -53,7 +53,7 @@ and 52 — it does not weaken either prior gate.
`titleLooksLikeVerbPhrase`.** The corpus is a source-of-truth fixture,
`apps/api/tests/fixtures/legitimate-verb-phrase-titles.txt` (one title per
line; `#` comment and blank lines ignored). This is a universal claim: the
gate enumerates every non-comment line from that file and loops the *real*
gate enumerates every non-comment line from that file and loops the _real_
validator over each one — no single-case cheat. The corpus must encode the
dogfood regression anchor (`Partner accepts a shared-budget invitation`) and a
representative spread of common finite verbs the prior closed set omitted, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ gate. It extends the same envelope discipline to the `actor` read/by-id commands
cannot resolve the supplied actor, the emitted envelope names the lookup key
the agent passed and its `suggested_next_actions` points at `vspec actor list`
(so the agent can retry with the listed id). Behaviour — in both `--format
agent` and the default human output — is locked by unit tests in
agent` and the default human output — is locked by unit tests in
`apps/cli/tests/unit/actor-command.test.ts`.
3. **The actor read/write happy paths stay green.** `actor list`/`show`/`edit`/
`archive` success output is unchanged, and the success cases still emit no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ guide's examples used the literal placeholder `<main-scenario-id>`. The dogfood
agent could not template the command, so it fell back to grepping
`vspec usecase show TODO-001 --format=agent | python3 … data['scenarios'][].id`
— running `vspec usecase show` six times to dig the id back out of JSON. Its own
narration: *"Step adds need a scenario id, not the use-case key. … neither
narration: _"Step adds need a scenario id, not the use-case key. … neither
usecase create nor scenario add's tail output surfaced that id clearly. … echoing
the new scenario id prominently on scenario add would close that gap."*
the new scenario id prominently on scenario add would close that gap."_

The root cause is the API success path. `sendCreateScenarioResult`'s `CREATED`
branch in `apps/api/src/http/scenario-results.ts` sends `scenario`/`revision`/
Expand Down Expand Up @@ -67,7 +67,7 @@ lists in `scenario-results.ts`). It extends that discipline to the scenario

1. **Every scenario type the contracts enum declares yields a real-id step-add
next action on create.** The source of truth is `scenarioTypeSchema =
z.enum([...])` in `packages/contracts/src/scenario.ts`. This is a universal
z.enum([...])` in `packages/contracts/src/scenario.ts`. This is a universal
claim: the gate enumerates every enum member from that line and, for each,
drives the **real** `sendCreateScenarioResult` (from
`apps/api/src/http/scenario-results.ts`) over a `CREATED` result whose
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
The dogfood agent authored `POCKET-001` **entirely through CLI write commands**
(`vspec usecase create`, `vspec scenario add`, `vspec step add` — narration
commands 10, 12, 14-20). It made **zero** direct edits to any synced spec file
(the digest's *"Direct edits to synced spec state"* section is empty). Yet
(the digest's _"Direct edits to synced spec state"_ section is empty). Yet
command 24, the very first `vspec push`, reported a **conflict** on the local
markdown file:

> *"Push reports a conflict on the local markdown file ... The server already
> _"Push reports a conflict on the local markdown file ... The server already
> holds my latest revision (`113902c9`); the local markdown cache is stale. Let
> me pull to reconcile."*
> me pull to reconcile."_

The agent then had to run command 29 `vspec pull --format=agent` followed by
command 30 `vspec push` just to reconcile a file it had never hand-edited. A
Expand Down
Loading
Loading