Skip to content

test(guards): scan wrapper buttons for dead affordances and name the primary-action inventory - #2663

Merged
Chris0Jeky merged 4 commits into
mainfrom
issue-1949/wrapper-actionability
Sep 5, 2026
Merged

test(guards): scan wrapper buttons for dead affordances and name the primary-action inventory#2663
Chris0Jeky merged 4 commits into
mainfrom
issue-1949/wrapper-actionability

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

PaperHLBtn, TdButton and TdIconButton each render a real native <button> root, so the component files always passed the existing dead-affordance scan. Their call sites did not pass anything, because nothing scanned them. An enabled <PaperHLBtn label="Apply" /> with no @click shipped a focusable, enabled control that does nothing on activation, and because that markup contains no click handler at all, ESLint's vuejs-accessibility/click-events-have-key-events never fired on it either. That is the #1941 defect in component clothing, across roughly 150 unscanned call sites in 35 SFCs.

This PR adds a call-site scan for those three components and wires it into a repo-wide assertion, then disposes of every offender the scan found. It also makes the AC3 registry state its own scope: the registry proved two controls and said nothing about which controls it had examined, so a reader could not tell whether an absent control was safe or merely unlooked-at.

No production behaviour changes. The only production edit is seven data-dead-affordance-exempt="visual-specimen" attributes on style-guide specimens, which land on the rendered <button> as ordinary fallthrough data attributes.

Refs #1949.

Closed by this PR: AC1's wrapper-component half (call sites of the three wrapper button components are now scanned and clean) and AC3's stated-inventory gap (the registry now names its covered surfaces, every precondition each of their disabled bindings reads, and what is deliberately out).

Still open on #1949 after this: AC4 route-walking runtime coverage, and the parser LOWs already disclosed in the guard header (HTML-entity scheme obfuscation, runtime-assembled hrefs, dynamic event names, :type resolution). PaperReviewView's approve, reject and execute remain unregistered while #2629 holds those files.

Changes

ddf486298 test(guards): scan wrapper button call sites for dead affordances

src/tests/guards/deadAnchors.spec.ts. Adds WRAPPER_BUTTON_COMPONENTS (the button-shaped subset of the existing NATIVE_INTERACTIVE_COMPONENTS) and findDeadWrapperButtons. A call site is reported unless it binds a click, uses v-on object syntax with a click key, carries a static type="submit" with a provable form owner on a component that declares a type prop, is permanently disabled, or carries the specimen marker. Form ownership reuses the existing formIds and hasFormOwner rather than restating the proof, and form depth is tracked across the same match stream as the wrapper tags so document order is respected.

Two deliberate departures from the native-button clause, both documented in the code:

  • The submit rule requires an explicit static type="submit". A native <button> inside a form defaults to submit, but PaperHLBtn and TdButton declare type with a 'button' default, so an untyped wrapper inside a form submits nothing. Treating a missing type as submit here would have silently exempted a real dead control.
  • v-on="{ click: fn }" redeems a wrapper call site, unlike on a native control where the compiler-backed scan reports it as unproven. On these components click is the declared emit, so object syntax with a spelled-out key genuinely wires activation.

directiveOnlyMarkup now keeps a bare v-on alongside v-on:x, so object syntax is read through the same attribute-boundary parse that already rejects an inert data-note='@click="run"'. Nothing else keys off a bare v-on, and no existing finding changes.

The header's disclosure list is updated: the "component tags are not scanned" bullet is replaced with what is now scanned and what still is not, including the three live-at-runtime shapes that are reported anyway.

d4ec44c66 test(guards): mark the seven inert PaperHLBtn style-guide specimens

src/views/PaperStyleGuideView.vue. The only production file touched, and only to add the marker attribute plus two explanatory comments.

b3484f06e test(guards): state the AC3 primary-action inventory and register six preconditions

src/tests/guards/primaryActionGuards.spec.ts. Header defines a primary action as one whose activation writes or enqueues, and lists what is covered and what is out. Six new registrations back it. No change to guardedPrimaryAction.ts; its contract was sufficient as-is.

4ad6519f0 test(guards): address round-2 review on the wrapper scan and AC3 inventory

Two MEDIUMs and four LOWs, one commit, no production files.

  • MEDIUM 1 — the AC3 header claimed to list every precondition each control can be blocked by, and did not. Composer submit is a four-conjunct canSubmit (non-blank body, !submitting, selectedBoardIsWritable, !transcriptTooLong); row-edit save is saveBlock !== null || saving; accept-on-board is boardPickBlock !== null || isActionDisabled(item) with a six-way isActionDisabled. Each bullet now enumerates every disjunct or conjunct the binding reads and marks it REGISTERED or not-registered with a reason (transient in-flight state, parent-owned prop, or out of this slice). The header states outright that an unmarked entry means unexamined, never safe, and that registering one precondition proves only that the control honours its binding at all.
  • MEDIUM 2hasVOnObjectBinding accepted any non-empty v-on object, so v-on="{ mouseenter: showHint }" was redeemed while @mouseenter on the same tag was reported. It now requires a spelled-out click key in any of the four JS spellings ({ click: fn }, { 'click': fn }, { "click": fn }, and the { click } shorthand). Rule chosen for the unprovable case: report it. v-on="listeners" and a spread-only object are now reported, not accepted, because source text cannot say what they resolve to and the compiler-backed scan already treats that exact shape on a native control as unproven action evidence; accepting it on a wrapper would have been an inconsistency. The old listeners pin is inverted accordingly, and a new test block covers mouseenter-only, dblclick, clickOutside and onclick as reported shapes alongside the accepted spellings. No v-on call site of any kind exists today, so the strictness costs nothing now.
  • LOW 3TdIconButton declares no type prop and hard-codes type="button" on its root, so the comment claiming all three declare a type prop with a 'button' default was wrong for it. Comment corrected, and TdIconButton is now excluded from the submit clause via a new SUBMIT_CAPABLE_WRAPPERS set rather than keeping an exemption that would rest on unverified fallthrough merge precedence over an explicit root attribute. A test pins <form><TdIconButton type="submit"> as reported and <form><TdButton type="submit"> as clean.
  • LOW 4 — added the kebab spellings paper-h-l-btn, td-button and td-icon-button, matching how the shared native list already carries router-link beside routerlink. The subset test now compares on the de-hyphenated name, and a second loop pins SUBMIT_CAPABLE_WRAPPERS as a subset of the scanned set. A new test block covers both spellings reaching the same verdict, and that <td-button-group> is still not a prefix match.
  • LOW 5 — the vacuity canary counted files while being named and described as call sites, so it would have passed with one call site left per file. It now counts tag matches through a dedicated WRAPPER_CALL_SITE regex built from the same set, with the threshold raised from 20 to 100 against a real 156.
  • LOW 6 — the loadFailed registration's comment said the accept click triggers the failing load. onMounted primes it and onAccept re-triggers only from idle, so the load has already failed by the time the picker opens. Comment corrected and an explicit flush added after mount so the described sequence is the one that runs.

Test plan

Run from frontend/taskdeck-web after npm ci.

Verified at head 4ad6519f0

  • npx vitest --run --maxWorkers=2 src/tests/guards/deadAnchors.spec.ts src/tests/guards/primaryActionGuards.spec.ts src/tests/guards/shortcutLedgerTruth.spec.ts src/tests/guards/shortcutNotation.spec.ts — 4 files passed, 47 tests passed (45 at round 1; the two new blocks are the v-on click-key rule and the kebab-spelling coverage). deadAnchors went from 18 at base to 26; primaryActionGuards from 5 to 11.
  • npx vitest --run --maxWorkers=2 src/tests/views/paper/inbox/PaperTriageTable.spec.ts src/tests/views/paper/inbox/PaperTriageRowEdit.spec.ts src/tests/views/paper/inbox/PaperCaptureNib.spec.ts — 3 files passed, 95 tests passed. These are the specs of the three components the registrations mount.
  • npm run typecheck — exit 0.
  • npx eslint src/tests/guards/deadAnchors.spec.ts src/tests/guards/primaryActionGuards.spec.ts — clean.
  • npm run lint (the job CI runs, eslint . --max-warnings=20) — 0 errors, 8 warnings, under budget. All 8 are pre-existing and in files this PR does not touch: TdDateField.vue, AgentRunDetailView.vue, AgentRunsView.vue, AgentsView.vue, PaperCaptureComposer.vue.
  • npm run build — succeeded, PWA precache 142 entries.
  • git diff --check — clean.

Red-first evidence (recorded at round 1, still the evidence for the detector)

Repo-wide scan, recorded at ddf486298 before any marker landed. never ships a wrapper button call site without an action failed with exactly 7 offenders, and the other 23 tests in the file stayed green (so widening directiveOnlyMarkup changed no existing finding):

AssertionError: expected [ …(7) ] to deeply equal []
+   "src/views/PaperStyleGuideView.vue: <PaperHLBtn label=\"Default\" kbd=\"⌫\" />",
+   "src/views/PaperStyleGuideView.vue: <PaperHLBtn variant=\"primary\" label=\"Primary\" kbd=\"P\" />",
+   "src/views/PaperStyleGuideView.vue: <PaperHLBtn variant=\"ember\" label=\"Apply\" kbd=\"⏎\" />",
+   "src/views/PaperStyleGuideView.vue: <PaperHLBtn variant=\"ghost\" label=\"Ghost\" />",
+   "src/views/PaperStyleGuideView.vue: <PaperHLBtn label=\"Capture\" kbd=\"space\">",
+   "src/views/PaperStyleGuideView.vue: <PaperHLBtn label=\"Default\" kbd=\"⌫\" />",
+   "src/views/PaperStyleGuideView.vue: <PaperHLBtn variant=\"ember\" label=\"Apply\" kbd=\"⏎\" />",

Mutation A, detector blinded (findDeadWrapperButtons returns [] unconditionally). 6 failed, 18 passed: the live-detector canary plus all five detector blocks that assert a shape IS reported. Reverted.

Mutation B, every exemption clause disabled (report every wrapper tag). 4 failed, 20 passed: all three blocks that assert a shape is CLEAN, plus the repo-wide assertion. Reverted.

Together these two runs prove the detector can fail in both directions rather than being a green no-op. The round-2 rules are pinned by assertions in both directions in the same style: v-on="{ click: apply }" clean against v-on="{ mouseenter: showHint }" reported, <form><TdButton type="submit"> clean against <form><TdIconButton type="submit"> reported.

Registry vacuity check. With isDisabled in guardedPrimaryAction.ts temporarily forced to false, 7 of the 8 registrations failed with "Enabled-and-silent primary action", proving each one really locates and evaluates its trigger. The one that still passed is the loadFailed registration, because that state renders its reason with role="alert" and therefore satisfies both branches of the contract, not just the disabled one. Reverted; guardedPrimaryAction.ts is byte-identical to the base and appears in no commit on this branch.

Repo-wide offender list and disposition

The scan reported 7 offenders. All 7 are in src/views/PaperStyleGuideView.vue, and all 7 are intentional visual specimens: the five PaperHLBtn variant swatches in the primitives section, and the two in the opposite-theme preview frame. Both rows exist to show what a variant looks like, so there is no intended handler to restore. Disposition: marked with data-dead-affordance-exempt="visual-specimen", the same narrow opt-out the four native .pbtn specimens a few lines above in the same file already carry.

Zero product offenders. Of the 156 wrapper call sites the scan walks, 142 bind @click, 7 are type="submit" inside a real <form> (BoardsListView, IntegrationsView, NotificationPreferencesView, PaperCardComposer, PaperBoardView twice, ProfileSettingsView), and 7 were the specimens above. Nothing needed a handler added, so the "obvious missing handler" carve-out was not used and no production logic changed.

NOT verified

  • Playwright. No E2E run; not required for this slice and no route behaviour changed.
  • Whether PaperStyleGuideView renders correctly in a browser. That view has no spec anywhere in the repo, so npm run build, npm run typecheck and the guard are its only coverage here. The change is additive data attributes on a component that already spreads fallthrough attrs onto its <button> root.
  • The full frontend vitest suite. Only the guard specs and the three component specs the registrations mount were run.
  • Backend. Untouched.
  • Whether the wrapper components' rendered DOM matches their contract. Still a component-test question; the header says so.
  • Whether Vue's fallthrough merge precedence would let type="submit" override TdIconButton's hard-coded root type="button". Deliberately not relied on and not measured — the scan now reports that shape instead of exempting it.
  • Every precondition the AC3 header marks "not registered". They are enumerated so a reader can see the gap, not because they are proven.
  • Whether the validation text on any registered control is correct or announced to assistive tech. Out of AC3's contract, and the registry header says so.

Boundaries and risks

Held files, not edited. PaperTriageTable.vue and PaperCaptureComposer.vue are held by #2654; PaperReviewView.vue and its spec by #2629. None of them is in this diff. The three files changed here are src/tests/guards/deadAnchors.spec.ts, src/tests/guards/primaryActionGuards.spec.ts and src/views/PaperStyleGuideView.vue.

Registrations that mount a held file. The registry mounts PaperTriageTable and PaperCaptureComposer, as it already did before this PR. The two new PaperTriageTable registrations depend on this markup: button[data-action="accept"], button[data-action="accept-on-board"], button[data-action="retry-board-load"], [data-testid="board-pick-reason"] with its data-reason, the board <select> and its :value="board.id" options, and the canWrite gating behind boardPickBlock.

Checked against #2654's head as of this PR: that PR is a two-line i18n change replacing the hard-coded text of two placeholder <option> labels with t(...) calls. It touches none of the hooks above, so these registrations survive it as it stands. If #2654's head moves and changes any of that markup, the two new PaperTriageTable registrations are the ones to drop; the other four new registrations touch only PaperTriageRowEdit and PaperCaptureNib, which nothing holds.

Risk: the directiveOnlyMarkup widening. It is a one-token change to shared parser code used by the anchor, native-button and aria-label finders. Nothing else reads a bare v-on, and the full deadAnchors file was green on the same commit that introduced it, including the existing test that a native <button v-on="{ click: run }"> is still reported. A detector case pins that an inert data-note='v-on="{ click: run }"' is still reported, so the attribute-boundary parse did not loosen.

Risk: the guard is stricter than the runtime in three places. A wrapper call site bound only by @mousedown, one bound by a v-on expression that never spells the click key, and a <TdIconButton type="submit"> inside a form are all reported despite being potentially live. Each is deliberate, documented at its constant, and costs nothing today because no such call site exists. The cost would be a future false positive, and the header tells the next agent what each one means.

Not a risk this PR takes on. The scan is source-text only. It does not compiler-expand the wrapper components, so it still cannot see a handler passed through v-bind="attrs" or assembled at runtime. The header says so rather than implying the call sites are now fully proven.

Worktree. .worktrees/codex-1949-wrapper-actionability, ready for plain git worktree remove once this is merged. git status --porcelain --ignored lists only frontend/taskdeck-web/dist/ and frontend/taskdeck-web/node_modules/, both regenerable build artifacts. Nothing was copied out because nothing needed to survive.

PaperHLBtn, TdButton and TdIconButton each render a native <button> root and
declare click as an emit, so the component file passes the native-button scan
while an unbound call site ships an enabled control that does nothing. Nothing
scanned the ~150 call sites, and ESLint's click-events-have-key-events cannot
fire on markup with no click handler at all.

findDeadWrapperButtons reports a call site unless it binds a click, uses v-on
object syntax, carries a static type=submit with a proven form owner, is
permanently disabled, or is a marked visual specimen. Form ownership reuses
formIds and hasFormOwner rather than restating the proof.

The submit clause is deliberately not a copy of the native one: all three
components default the type prop to 'button', so an untyped wrapper inside a
form submits nothing, unlike a native button.

directiveOnlyMarkup now keeps a bare v-on alongside v-on:x so object syntax is
read through the same attribute-boundary parse that rejects an inert
data-note='v-on="{ click: run }"'. No existing finding changes.

The repo-wide assertion is red at this commit with the seven style-guide
specimens the next commit marks.
The wrapper scan's repo-wide red output named exactly seven offenders, all in
PaperStyleGuideView.vue: the five PaperHLBtn variant specimens and the two
opposite-theme preview specimens. Both rows exist to show what a variant looks
like, so there is no intended handler to restore and no product control is
affected. They carry the same narrow data-dead-affordance-exempt marker the
four native .pbtn specimens in the same view already use.

No product offender was reported, so nothing outside the style guide changed.

The attribute lands on PaperHLBtn's rendered <button> root as an ordinary
fallthrough data attribute; there is no behaviour change.
… preconditions

The registry proved two controls and said nothing about its own scope, so a
reader could not tell whether an absent control was safe or merely unexamined.
The header now defines a primary action as one whose activation writes or
enqueues, lists every covered surface with the preconditions each can be
blocked by, and names what is deliberately out: PaperReviewView's approve,
reject and execute while GH-2629 holds those files, the native form submits,
and every other writing control.

Six registrations back the inventory:
  PaperTriageRowEdit save - empty, unchanged, busyElsewhere (its three
  saveBlock states, so a change that keeps the button enabled for one cannot
  pass by satisfying the others).
  PaperCaptureNib submit - empty draft.
  PaperTriageTable accept-on-board - loadFailed and viewOnly, joining the
  noBoard registration already here.

Each keeps the branch-pinning disabled assertion and adds a data-reason
assertion, so a test cannot silently drift onto a different blocked state:
saveBlock reports busyElsewhere before empty, and boardPickBlock reports
noBoard before viewOnly.

The viewOnly path models write access being revoked on an already picked
board, because the picker disables a read-only option and the state is
otherwise unreachable from the UI.

No production file changes.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review record (alpha product-trust lane, review-and-ship round 1 at head b3484f0).

Reviewer: one fresh-context independent reviewer subagent (read-only), input = merge-base..head diff plus the worktree at the head. Verdict: SHIP, no CRITICAL or HIGH: no false green or false red constructible on markup that exists in src today, and the shared-parser change (keeping a bare v-on) is relax-only and cannot redden any existing finding.

Confirmed clean: no wrapper component has a link mode; the $attrs and component-is fallthrough classes are empty today; the tokenizer handles quoted > and multi-line tags and mirrors the native form-depth tracking; the repo-wide scan is green by construction (every unbound wrapper is one of the seven marked style-guide specimens or a type="submit" inside a same-file form); the specimen marker lands as an inert data attribute; the AC3 registry mounts nothing from PaperReviewView; each registration asserts the disabled branch and the multi-state ones also assert data-reason; none of the six registrations depends on text the other session's #2654 label change touches.

Triage, all taken in one round-2 commit (guards and comments only; no production change):

  • MEDIUM: the AC3 header claims every precondition of each control is listed, but the composer submit has four conjuncts, the row-editor save has a fourth block (saving) and the table accept has a six-way isActionDisabled beyond the registered ones; the header now lists every precondition and marks which are registered.
  • MEDIUM: v-on object syntax redeemed a wrapper on any key (v-on="{ mouseenter: showHint }" passed while @mouseenter was reported); the rule now requires a click key.
  • LOWs: TdIconButton declares no type prop (comment corrected); kebab spellings td-button and td-icon-button added to the wrapper set; the vacuity canary counts call sites, not files; the loadFailed trigger comment corrected. The attribute-boundary note (type, disabled and the marker are read from raw tag text) is inherited from the native scan and recorded, not changed.

Merge gate: ci-required green at the round-2 head, aged three minutes, then merge commit; no further review round because round 2 changes guard rules and comments only, re-proved with the four guard specs and npm run lint.

…ntory

MEDIUM 1. The AC3 header claimed to list every precondition each control can
be blocked by, but three controls have more than the registrations cover:
composer submit is a four-conjunct canSubmit, row-edit save is saveBlock ||
saving, and accept-on-board is boardPickBlock plus a six-way isActionDisabled.
Each bullet now enumerates every disjunct or conjunct the binding reads and
marks it REGISTERED or not-registered with a reason, and the header states that
an unmarked entry means unexamined rather than safe.

MEDIUM 2. hasVOnObjectBinding accepted any non-empty v-on object, so
v-on="{ mouseenter: showHint }" was redeemed while @mouseenter on the same tag
was reported. It now requires a spelled-out click key in any of the four JS
spellings, including the { click } shorthand. An expression that never spells
the key - v-on="listeners", or a spread-only object - is REPORTED rather than
accepted: source text cannot say what it resolves to, and the compiler-backed
scan already treats that shape on a native control as unproven. The former
listeners pin is inverted and a new block covers mouseenter-only, dblclick,
clickOutside and onclick as reported shapes.

LOW 3. TdIconButton declares no type prop and hard-codes type=button on its
root, so the old comment was wrong for it. Corrected, and TdIconButton is now
excluded from the submit clause via SUBMIT_CAPABLE_WRAPPERS rather than resting
the exemption on unverified fallthrough merge precedence.

LOW 4. Added the kebab spellings paper-h-l-btn, td-button and td-icon-button,
matching how the shared native list already carries router-link beside
routerlink. The subset test compares on the de-hyphenated name, and a second
loop pins SUBMIT_CAPABLE_WRAPPERS as a subset of the scanned set.

LOW 5. The vacuity canary counted FILES while being named and described as call
sites, so it would have passed with one call site left per file. It now counts
tag matches through WRAPPER_CALL_SITE, with the threshold raised from 20 to 100
against a real 156.

LOW 6. The loadFailed registration's comment said the accept click triggers the
failing load. onMounted primes it and onAccept re-triggers only from idle, so
the load has already failed by then. Comment corrected and an explicit flush
added after mount so the described sequence is the one that runs.

No production file changes in this commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant