Skip to content

fix(indicator): the decorative contract is not a caller's to switch off (#4918) - #4921

Merged
cixzhang merged 2 commits into
mainfrom
fix-4918
Aug 12, 2026
Merged

fix(indicator): the decorative contract is not a caller's to switch off (#4918)#4921
cixzhang merged 2 commits into
mainfrom
fix-4918

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #4918, and closes the doc finding from the same audit under the rubric rule it prompted.

1. A caller could switch off the decorative contract (#4918)

An indicator is aria-hidden by contract: the owning control supplies the role and the accessible name. An indicator a consumer un-hides gets that control announced twice — the harm the docs already name at Indicator.doc.mjs:241.

All three forwarded a caller's aria-hidden, role and aria-label straight through. Measured on main:

<CheckIndicator     state="checked" aria-hidden="false" />  →  aria-hidden="false"
<CheckboxIndicator  state="checked" aria-hidden="false" />  →  aria-hidden="false"
<RadioIndicator     state="checked" aria-hidden="false" />  →  aria-hidden="false"

Rubric P3 puts owned aria-* after {...rest}, at BLOCK severity. Pre-existing, and missed by all three prior audit passes — including the two I ran on this component today.

Enforced in the type, not by stripping props at runtime. My first pass destructured the a11y props into throwaway _ariaHidden bindings, which makes a prop vanish with no signal to the caller. Cindy's call, and she is right: say it in the type.

The type gets exactly half of it, and which half is worth knowing:

prop shape result
role identifier excess-property check applies → compile error
aria-hidden, aria-label, aria-labelledby hyphenated TypeScript exempts it → compiles, always

That is a TypeScript language rule, not a gap in our types — a JSX attribute name that is not a valid JS identifier is exempt from excess-property checking. Proven on a bare interface with no Astryx involved:

interface Tight { state: string }
function Tight({state}: Tight) { return <span>{state}</span>; }

<Tight state="x" aria-hidden="false" />   // compiles
<Tight state="x" data-whatever="y" />     // compiles
<Tight state="x" role="checkbox" />       // TS2322 ✓

So: IndicatorProps omits all four (intent, and role genuinely enforced), and each component emits its own aria-hidden after {...rest} — attribute order, which is rubric P3's literal prescription, and which is what actually holds. CheckIndicator's glyph path re-asserts it on the Icon call for the same reason: Icon puts its a11y defaults before {...props} as a deliberate escape hatch, right for an icon and wrong for something decorative by contract.

Nothing is stripped. A forwarded aria-label reaches the DOM and is inert inside an aria-hidden subtree — forwarding aria props is fine, which was the point.

The test documenting the TS exemption carries no @ts-expect-error, deliberately: it compiles today, and it will start failing the day TypeScript changes its mind — which is when the ordering can go.

2. The docs still taught the bug we fixed last hour

Indicator.doc.mjs was the only worked example of writing a replacement indicator, and it handed out:

{children ?? (state === 'checked' ? <StarGlyph /> : null)}

That is the exact shape #4913 removed from all three shipped indicators, because it deletes the state mark on a falsy child (#4893). The components were fixed and the documentation still instructed readers to rebuild the bug — on a surface the rubric itself calls LLM training signal.

Now uses isRenderable, and the bestPractice that said a replacement "must render children when present" says what present means and why ?? is the trap.

This had no rule, so I added one rather than stretching an existing one. The auditor who found it filed it under X5; X5a is a phantom prop and X5b is a missing prop entry, and this is neither, so I overruled the citation and recorded a FIX — which left a BLOCK-severity defect scored as cosmetic. The real defect was in the rubric. Wiki 90431f1 adds X21 (§8, BLOCK): a worked doc example may not demonstrate a pattern the library has deliberately removed, scoped so it cannot become a style cudgel — the test is whether following the example re-creates a defect the repo has already paid to remove. Rubric is now v1.3 (minor, not patch: a new check that can BLOCK changes what counts, so §8 scores under ≤1.2.1 are not comparable).

Test plan

The new tests fail against main — both halves. Reverted the sources in this tree and re-ran:

× CheckIndicator (glyph path) stays aria-hidden even when a caller passes false
× CheckboxIndicator            stays aria-hidden even when a caller passes false
× RadioIndicator               stays aria-hidden even when a caller passes false
   Tests  3 failed | 36 passed (39)

tsc: Indicator.test.tsx(227,5): error TS2578: Unused '@ts-expect-error' directive.
     ^ the `role` rejection, red because main still accepts it

All 45 pass with the fix. Note the children path already passed the aria-hidden case — #4913 had put its spread first with a comment saying why — which is what made the inconsistency between CheckIndicator's own two paths visible.

Suites: 384 tests green across Indicator + CheckboxInput + RadioList + Selector + DropdownMenu + CheckboxList; theme 618 green; core tsc clean; typecheck:docs clean; CI=true eslint --no-cache 0 errors 0 warnings; docsite generate + 364 tests green; check:changesets 56 valid.

Ledger

Indicator is recorded at 82.4 / C (cd0b9f6340, rubric v1.2.1) with P3 as its one open BLOCK. Under v1.3 that same commit also carries an X21 BLOCK, so the honest current score is lower than the row says. I will re-audit under v1.3 and re-record once this merges, per the rubric's closing protocol — rather than record a score now and overwrite it in twenty minutes.

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 12, 2026 8:38am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 12, 2026
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Indicator (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 555 -
Complexity N/A High (26) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.7KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

An indicator is aria-hidden by contract: the owning control supplies the
role and the accessible name, so an indicator a consumer un-hides gets
that control announced twice. All three forwarded a caller's aria-hidden,
role and aria-label straight through, so `<CheckIndicator
aria-hidden="false" />` rendered exactly that. Rubric P3 puts owned aria-*
after {...rest}, at BLOCK severity.

Pre-existing and missed by all three prior audit passes, including the two
I ran on this component today.

Two mechanisms, because one does not cover both cases:

  - Spread order on the siblings: {...rest} moves ahead of their own
    aria-hidden, which is P3's letter.
  - Stripping the three props on all three: ordering cannot help
    CheckIndicator's glyph path, because it renders Icon and Icon
    deliberately lets a caller override its a11y defaults as a documented
    escape hatch. Forwarding would hand that hatch to an element that must
    never take it. And role/aria-label are not emitted by the siblings at
    all, so ordering cannot stop those either -- my own test caught that
    after the first attempt.

Ordinary props still forward: data-*, id, handlers and styling are pinned
by a negative control in each case, so "strip a11y" cannot quietly become
"strip everything".

Also fixes the doc example, under the rubric rule this prompted (X21,
added in wiki 90431f1): Indicator.doc.mjs was the only worked example of
writing a replacement indicator, and it still taught `{children ?? mark}`
-- the exact shape #4913 removed from all three shipped components because
it deletes the state mark on a falsy child (#4893). The example now uses
isRenderable, and the bestPractice that said "render children when
present" now says what "present" means and why `??` is the trap.

Verified: 7 of the new tests go red against origin/main; 386 pass across
the subject and every consumer; core tsc, typecheck:docs and strict lint
clean; docsite generate + 364 tests green.
… by stripping

Replaces the runtime strip from the previous commit. Dropping a caller's
props into `_ariaHidden` and friends made a prop vanish with no signal;
a type says so at the call site.

The type only gets half of it, and the half matters:

  role         identifier -> excess-property check applies -> compile error
  aria-hidden  hyphenated -> TypeScript EXEMPTS it -> compiles, always

That is a language rule, not a gap in our types -- proven with a probe on
a bare interface with no Astryx involved. So `IndicatorProps` omits all
four (intent, and `role` actually enforced), and each component emits its
own `aria-hidden` AFTER `{...rest}`, which is what keeps a caller from
un-hiding it. That is rubric P3's literal prescription, and it is
attribute order rather than a disappearing prop.

CheckIndicator's glyph path re-asserts `aria-hidden` on the Icon call for
the same reason: Icon puts its a11y defaults BEFORE `{...props}` as a
deliberate escape hatch, which is right for an icon and wrong for an
element that is decorative by contract.

Nothing is stripped now. A forwarded `aria-label` reaches the DOM and is
inert inside an aria-hidden subtree -- forwarding aria props is fine,
which was the point.

The test that documents the TS exemption carries NO @ts-expect-error on
purpose: it compiles today, and it starts failing the day TypeScript
changes its mind, which is when the ordering can go.

Verified: 3 runtime tests + the `role` type test go red against
origin/main; 384 pass across the subject and every consumer; core tsc,
typecheck:docs and strict lint clean.
@cixzhang
cixzhang merged commit 6537294 into main Aug 12, 2026
19 checks passed
github-actions Bot added a commit that referenced this pull request Aug 12, 2026
cixzhang added a commit that referenced this pull request Aug 12, 2026
#4921 Omitted four members from the exported IndicatorProps and shipped
it as a `patch` under [fix]. That is a retyped public export, which
rubric P11 names explicitly and puts at BLOCK: it needs [breaking] +
minor + a codemod. The changeset even advertised the break in prose.
check:changesets only enforces category-to-bump, so CI could not see it.

My own PR three commits earlier set the precedent I then ignored:
remove-dropdown-menu-radio-dot-target shipped [breaking] + minor + a
codemod for a strictly QUIETER break -- silent at runtime rather than red
at compile time.

Found by an independent re-audit under rubric v1.3, not by me.

  - changeset re-issued as [breaking] / minor.
  - New codemod remove-indicator-a11y-props, staged in transforms/next.
    It reaches BOTH shapes: a direct <CheckIndicator .../> and the
    documented `const Mark = useIndicator('check')` form, where no
    indicator name appears in the JSX at all. Removal, not rewriting --
    there is nowhere to move these props to -- and every removal leaves a
    TODO, because "the owner already carries the name" is a claim about
    the call site a codemod cannot verify.

Also closes two FIXes from the same audit:

  - tabIndex joins the omission list, and is dropped at runtime. This
    diff created the trap: aria-hidden became unconditional, so a
    forwarded tabIndex is now a focusable node inside a hidden subtree.
    Measured in real Chromium: axe aria-hidden-focus 0 -> 1 violation.
    My own jsdom probe had reported none -- jsdom cannot judge
    focusability, which is worth remembering about that harness.
  - Indicator.doc.mjs:55 still told a replacement to render children
    "when present" -- verbatim the wording #4921 replaced in
    bestPractices BECAUSE "present" is the trap.

And corrects an overstatement of mine: "passing role is a compile error"
holds only for a literal attribute. A spread -- the ordinary host idiom --
bypasses excess-property checking for every member, `role` included. The
type states intent; ordering and the codemod do the enforcing. A test now
pins that, deliberately without @ts-expect-error, so it fails the day
TypeScript changes its mind.

Verified: 4 new tests red against origin/main; 1134 pass across the
subject, every consumer and the codemods; core tsc, typecheck:docs and
strict lint clean; check:changesets 60 valid.
cixzhang added a commit that referenced this pull request Aug 13, 2026
* fix(indicator): a forwarded tabIndex no longer puts a tab stop in a hidden subtree

Indicators are unconditionally aria-hidden, so a focusable one is a
focusable node inside a hidden subtree -- axe aria-hidden-focus, measured
in real Chromium going from 0 violations to 1 the moment tabIndex is
forwarded. #4921 created that trap: before it, a consumer could pair
tabIndex with aria-hidden="false" and be valid.

`tabIndex` joins the props IndicatorProps omits, and is dropped at
runtime by a small internal helper for the case the type cannot see:
TypeScript exempts hyphenated JSX attributes from excess-property
checking, and a spread -- `<Mark {...props} />`, the ordinary host idiom
-- bypasses the check for every member. Only tabIndex is removed;
aria-hidden is settled by attribute order, and role/aria-label are inert
inside a hidden subtree, so they are left alone rather than silently
deleted.

This is the salvage from #4938, which was closed: that PR escalated the
same change to a [breaking] changeset plus an `astryx upgrade` codemod on
the theory that IndicatorProps is public API. It is not, yet --
core@0.3.0 published 2026-08-05 with no ./Indicator subpath, Indicator
landed 2026-08-11, and its changeset is still pending, so the layer ships
for the first time next release. Nothing to break, nobody to migrate.
Plain [fix] patch is the honest record.

Closing that PR also surfaced a bug in it: the type half of this change
was lost in a revert/restore during its development and never made it
into the commit, while its changeset advertised it. Only the runtime drop
shipped, and the tests passed because they exercise runtime. Both halves
are here, and I checked the type change survived this time.

Also corrects two doc claims, both from the same audit:
  - Indicator.doc.mjs:55 still said a replacement must render children
    "when present" -- verbatim the wording #4921 replaced in
    bestPractices BECAUSE "present" is the trap.
  - "passing role is a compile error" holds only for a literal
    attribute; a spread bypasses the check. A test pins that,
    deliberately without @ts-expect-error, so it fails the day
    TypeScript changes its mind.

Verified: 4 new tests red against origin/main; 391 pass across the
subject and every consumer; core tsc, typecheck:docs and strict lint
clean.

* Slim to the type: drop the runtime strip and its module

Cindy's review, and she is right on both counts.

Nobody sets tabIndex on an indicator -- grepped every call site in core,
zero. And `tabIndex` is a plain JS identifier, so excess-property
checking applies and the Omit rejects the literal on its own; I verified
the @ts-expect-error is consumed. The type was already doing the work.

So stripSmuggledProps.ts is deleted and all three components go back to a
plain {...rest}. What is left is one word in an existing Omit.

The module was also inconsistent, which I should have noticed while
writing it: we deliberately do NOT strip `role` or `aria-label` -- they
are inert inside an aria-hidden subtree -- so singling out tabIndex for a
runtime guard defended one spread-only case while leaving its siblings,
at the cost of a module and an indirection.

The runtime tests go with it; the spread case is now documented in the
test that shows it compiling, with the reason it is left alone.

Diff is now: one word in types.ts, one doc sentence, tests, changeset.
No .tsx source change at all.
@github-actions
github-actions Bot deleted the fix-4918 branch August 13, 2026 06:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

1 participant