Skip to content

docs(spec): ProgressBar records its shipped contract as current - #9135

Merged
cixzhang merged 1 commit into
mainfrom
spec-backfill/ProgressBar
Oct 31, 2026
Merged

cixzhang merged 1 commit into
mainfrom
spec-backfill/ProgressBar

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Exact decision needed

Promote component:ProgressBar from draft to current, recording ProgressBar's shipped behavior on main today. No behavior, API, or output change.

Existing authority searched

Decision

packages/core/src/ProgressBar/ProgressBar.spec.md: authority: current, approved_by: cixzhang, approved_at: 2026-10-31, owners unchanged, template 7, behavioral table headed "Invariant | Basis". DEC-1 is unchanged. New OQ2 asks whether the indeterminate loop stops under reduced motion.

Rejected alternatives

None.

Claim scope and non-goals

Covers only ProgressBar as shipped on main. External labels and values stay with the caller; the exact standalone treatment stays open (OQ1); the mark tooltip stays with component:Tooltip. No new behavior is decided.

Evidence and implementation relationship

  • FR3, ORD1, AR2: label, value text, formatter, clamp, zero and NaN tests; ProgressBar.a11y.test.tsx and ProgressBar.a11y.chromium.spec.ts.
  • FR4: track-height, track-clip, transform, variant, and forced-colors tests; ProgressBarMotion.a11y.chromium.spec.ts (leading edge, RTL region, reduced motion, per-frame cost).
  • FR5–FR7: disabled, indeterminate, and target-mark suites.
  • FR8: target-name and mark-target tests.
  • Stable visual baselines core-progressbar--default (neutral) and core-progressbar--with-target-mark (probe).

Current-state impact

  • FR3: the default value text and aria-valuetext are assembled in code, not one catalog message (architecture:internationalization INV1a).
  • FR7: marks are focusable spans with no role or accessible name (WCAG 2.2 SC 4.1.2).
  • FR4: a theme-added variant (the variant map is exported for augmentation) paints no fill until a theme styles it (architecture:component-theming-surface INV14).
  • FR6: the indeterminate slide and mark centering mirror under any right-to-left ancestor, not the bar's own direction (architecture:internationalization INV11).
  • FR8: a caller data-variant replaces the root's reflected variant (architecture:public-component-api INV5).

Writing rubric results

  • R1 narration: the draft's "This record also captures…", "0.7.0 removes…", "Observed current behavior", and "Draft decision" phrasing is rewritten in the present tense.
  • R2 research in the record: none.
  • R3 evidence naming a change: none.
  • R4 rejected alternatives over one line or outside a decision: none; DEC-1 keeps its one rejected line.
  • R5 pull request or issue references remaining: none (searched # + digits, /pull/, /issues/).
  • R6 ruled open questions still listing options: none; OQ1 and OQ2 are unanswered.

Scope

  • This PR contains specification records only.
  • It records observable behavior and preserves equivalent internal implementations.
  • Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract: none.
  • Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
  • It records one intentional durable decision, not a rescue for a separable tagalong.
  • The canonical knowledge owner, affected current claims, and overlapping open work are named; no ownership collision.
  • Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
  • Public text and artifacts contain no internal Meta context.

Validation

  • pnpm check:knowledge: aligned
  • Prettier: clean
  • git diff --check: clean

@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 31, 2026 3:00pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 31, 2026, 3:10 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026
@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 14:09

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering-owner decision. This head makes component:ProgressBar current, including DEC-1 and FR1–FR9, and no current record states those requirements yet. An engineering owner records the decision by commenting /approve-spec with the full SHA of the head they approve (the author may do this as an engineering owner). Separately, these record lines need fixing either way:

  • The Variant row says "Closed union; rejected by types", but ProgressBarVariantMap is exported for module augmentation, and an added variant paints no fill until a theme styles it.
  • FR9 requires caller data props to land "after the component's attributes", which is exactly the data-variant override that FR8 calls a gap; one of the two has to change.
  • The "Builder impact" row describes the data-variant override backwards.
  • The FR4 verification row says a track that is not 8px fails, but the "renders at fixed 8px track height" test only checks that the bar renders.
  • FR6 says the indeterminate slide mirrors under right-to-left, but it follows any dir="rtl" ancestor rather than the bar's own direction, unlike the determinate fill.

[Automated review]

| Value and range | finite `value` within `0`–`max` | Completed amount. | Determinate | 0 of 100 | caller | Stable | Clamped to `[0, max]`; a non-finite value or `max` counts as 0. |
| Label | string, visible or visually hidden | The bar's accessible name. | Always | visible | caller | Stable | — |
| Value text | hidden; shown; custom formatter | Optional visible value beside the label, also the `aria-valuetext`. | Determinate | hidden; rounded percent | `component:ProgressBar` | Stable | Ignored when indeterminate. |
| Variant | `accent`, `success`, `warning`, `error`, `neutral` | Fill color. | Always | `accent` | `component:ProgressBar` | Stable | Closed union; rejected by types. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProgressBarVariant is keyof ProgressBarVariantMap, which index.ts exports for module augmentation, so this axis is not closed. An added variant matches nothing in variantStyles/markOnFillStyles, so its fill paints no colour until a theme styles it, which conflicts with FR1 and component-theming-surface INV14. Consider recording the extension point plus a Current-state impact bullet for the missing baseline.

Comment on lines +136 to +137
| FR8 | The root carries `progress-bar` reflecting `variant`, the track `progress-bar-track`, the fill `progress-bar-fill` reflecting the resolved fill variant, and each mark `progress-bar-mark` reflecting variant and placement; the label and value text carry no target. Reflected state stays component-owned (`architecture:public-component-api` INV5). Implementation gap: a caller `data-variant` replaces the root's reflected variant (Current-state impact). | Current source and tests |
| FR9 | `ref` and `data-testid` reach the root; supported DOM, data, ARIA, and event props reach the root after the component's attributes; `xstyle`, `className`, and `style` compose after component styles. | Current source and tests; `architecture:public-component-api` INV5–INV8 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FR9's "reach the root after the component's attributes" is the ordering that lets a caller data-variant replace the reflected one, which FR8 calls a gap. As written, no implementation satisfies both; consider excluding reflected theme attributes from FR9.

| Public contract | `label`, `value`, `max`, `isLabelHidden`, `hasValueLabel`, `formatValueLabel`, `variant`, `isIndeterminate`, `marks`, `isDisabled`, root `BaseProps`, and a root `ref`. |
| Behavior | A labelled `progressbar` whose fill shows the clamped value against a muted track, or slides while indeterminate, with optional value text and labelled target marks. |
| End-user impact | People see how far an operation has gone, and assistive technology reads the name and value; marks reveal their labels on hover or focus. |
| Builder impact | A builder's `data-variant` is replaced by the reflected variant today (FR8 gap); otherwise none. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is backwards: {...rest} spreads after themeProps, so the builder's data-variant replaces the reflected variant, as Current-state impact already says.

| FR1, AR1, AR3 | Stable visual baselines `core-progressbar--default` (neutral) and `core-progressbar--with-target-mark` (probe) | Default; probe theme with a mark | A changed fill or track comes back changed for review. Gap: no real-browser contrast check across shipped themes and color modes proves the standalone distinction (OQ1 holds the treatment). | `audit:ProgressBar/visual` |
| FR2 | Public type and consumer-doc review | Current props | Gap: no automated check; a new visual-distinction prop is a review finding. | `audit:ProgressBar/api` |
| FR3, ORD1, AR2 | `ProgressBar.test.tsx` label, value text, formatter, clamp, zero and NaN tests; `ProgressBar.a11y.test.tsx` and `ProgressBar.a11y.chromium.spec.ts` states | Determinate; formatted; complete; label hidden | A missing name, a value outside `[0, max]`, a leaked `NaN`, or wrong `aria-valuetext` fails, including at the accessibility tree. Gap: no test checks the default text in a non-English locale. | `audit:ProgressBar/a11y` |
| FR4 | `ProgressBar.test.tsx` track-height, track-clip, transform, variant, and forced-colors tests; `ProgressBarMotion.a11y.chromium.spec.ts` | Every variant; value changes; RTL region; reduced motion; forced colors | A track that is not 8px or does not clip, a fill sized by width outside forced colors, a lost forced-colors outline, a leading edge off the value or unmirrored in an RTL region, a value change that does not land at once under reduced motion, or per-frame layout while moving fails. Gap: the variant colors have no direct check. | `audit:ProgressBar/visual` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"A track that is not 8px … fails": the renders at fixed 8px track height test (ProgressBar.test.tsx:104-107) only asserts that the progressbar is in the document, and no other test checks the height. Either list this as a gap or make the test assert it.

| FR3 | The root holds a label row and a track. The label names the track (`role="progressbar"`) through `aria-labelledby`; `isLabelHidden` hides it visually only. When determinate, the track exposes `aria-valuenow` (the clamped value), `aria-valuemin` 0, `aria-valuemax` (`max`), and `aria-valuetext` from `formatValueLabel`, which defaults to the rounded percentage; `hasValueLabel` shows the same text beside the label. A zero or non-finite `max` gives 0% and never renders `NaN`. Astryx-owned value text comes from one catalog message (`architecture:internationalization` INV1a). Implementation gap: the default text is assembled in code (Current-state impact). | Current source and tests |
| FR4 | The track is 8px high, fully rounded, in `--color-background-muted`, and clips the fill. The determinate fill spans the track and slides along it so its leading edge sits at the clamped percentage, mirrored by the bar's own writing direction, moving over `--duration-medium` when the value changes; under reduced motion a new value lands at once on every pointer (`design:reduced-motion` DR1). The fill paints `--color-accent`, `--color-success`, `--color-warning`, or `--color-error`, or `--color-text-disabled` for `neutral` and disabled bars. In forced colors the fill is sized to the value instead of sliding and keeps a 1px `CanvasText` outline so its shape stays visible (`architecture:theme-tokens` INV11). | Current source and tests |
| FR5 | `isDisabled` mutes the fill, label, value text, and marks and adds no ARIA state. | Current source and tests |
| FR6 | `isIndeterminate` removes every value attribute and the value text and slides a 40% fill across the clipped track, mirrored under right-to-left; under reduced motion the loop slows from 1.5s to 3s per pass. Whether the loop stops under reduced motion is open (OQ2). | Current source and tests |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indeterminate slide keys on :is([dir="rtl"] *), so it mirrors under any RTL ancestor, unlike the determinate fill's :dir(rtl) (FR4). In an LTR region of an RTL page it runs against the label. Mark centering has the same issue. Consider stating this and recording it under Current-state impact.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering-owner decision: this head makes component:ProgressBar current, including DEC-1 and FR1–FR9, and no current record states those requirements yet. An engineering owner records the decision by commenting /approve-spec with the full SHA of the head they approve, and the author can do this as an engineering owner. The five record lines flagged on the earlier head are fixed; because an approval binds the record text, please fold in these two fixes first:

  • FR8: say each mark reflects the resolved fill variant and placement, since a disabled bar's marks reflect disabled and ProgressBar.test.tsx pins that.
  • FR6 verification row: note that the indeterminate RTL test asserts the [dir="rtl"] ancestor selector, so it pins the FR6 gap, as the FR7 row already does for marks.

[Automated review]

| FR5 | `isDisabled` mutes the fill, label, value text, and marks and adds no ARIA state. | Current source and tests |
| FR6 | `isIndeterminate` removes every value attribute and the value text and slides a 40% fill across the clipped track, mirrored by the bar's own rendered direction (`architecture:internationalization` INV11); under reduced motion the loop slows from 1.5s to 3s per pass. Whether the loop stops under reduced motion is open (OQ2). Implementation gap: the slide, and the centering of marks, mirror under any right-to-left ancestor rather than the bar's own direction, so in a left-to-right region of a right-to-left page they run against the label (Current-state impact). | Current source and tests |
| FR7 | Each finite mark renders, outside the `progressbar` element, a tick at its clamped position whose tooltip shows its label on hover and focus; marks inside the filled part take the fill's contrasting color, and marks do not render while indeterminate. A focusable mark exposes its label as its accessible name (WCAG 2.2 SC 4.1.2). Implementation gap: marks are focusable `span`s with no role or name, labelled only as a description once the tooltip loads (Current-state impact). | Current source and tests |
| FR8 | The root carries `progress-bar` reflecting `variant`, the track `progress-bar-track`, the fill `progress-bar-fill` reflecting the resolved fill variant, and each mark `progress-bar-mark` reflecting variant and placement; the label and value text carry no target. Reflected state stays component-owned (`architecture:public-component-api` INV5). Implementation gap: a caller `data-variant` replaces the root's reflected variant (Current-state impact). | Current source and tests |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FR8 does not say which variant a mark reflects; shipped marks reflect the resolved fill variant, so a disabled bar's marks carry data-variant="disabled" (ProgressBar.tsx:625-628, pinned by ProgressBar.test.tsx:470-484). Suggest "each mark progress-bar-mark reflecting the resolved fill variant and placement".

| FR3, ORD1, AR2 | `ProgressBar.test.tsx` label, value text, formatter, clamp, zero and NaN tests; `ProgressBar.a11y.test.tsx` and `ProgressBar.a11y.chromium.spec.ts` states | Determinate; formatted; complete; label hidden | A missing name, a value outside `[0, max]`, a leaked `NaN`, or wrong `aria-valuetext` fails, including at the accessibility tree. Gap: no test checks the default text in a non-English locale. | `audit:ProgressBar/a11y` |
| FR4 | `ProgressBar.test.tsx` track-clip, transform, variant, and forced-colors tests; `ProgressBarMotion.a11y.chromium.spec.ts` | Every variant; value changes; RTL region; reduced motion; forced colors | A track that does not clip, a fill sized by width outside forced colors, a lost forced-colors outline, a leading edge off the value or unmirrored in an RTL region, a value change that does not land at once under reduced motion, or per-frame layout while moving fails. Gap: the variant colors have no direct check. Gap: the "fixed 8px track height" test only checks that the bar renders, so the track height has no check; no test covers a theme-added variant. | `audit:ProgressBar/visual` |
| FR5 | `ProgressBar.test.tsx` disabled and disabled-mark tests | Disabled | A missing label or undimmed marks fail. Gap: the muted fill and label colors have no direct check. | `audit:ProgressBar/visual` |
| FR6 | `ProgressBar.test.tsx` indeterminate suite | Every variant; RTL | Value attributes or value text while indeterminate, a lost label, an unclipped track, or an unmirrored slide fails. Gap: the reduced-motion loop duration has no check. No test covers the slide in a left-to-right region inside a right-to-left page. | `audit:ProgressBar/behavior` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indeterminate RTL test asserts the ancestor selector (ProgressBar.test.tsx:264 matches :is([dir="rtl"] …)), so it pins the FR6 gap; a fix to the bar's own direction fails it. Worth a "Gap:" note like the FR7 row's.

| FR1 | Every determinate presentation ProgressBar provides is a sufficient standalone visual for distinguishing completed from remaining progress; external visible text may supplement it but is never required, and supplementary text or graphics never substitute for that distinction. ProgressBar does not make visual correctness depend on caller-declared external content it cannot verify or associate. | `component:ProgressBar/DEC-1`; `spec:AST-002` |
| FR2 | New public API that adjusts the visual distinction is considered only after the component has a correct base behavior and a stable caller-owned distinction still remains. | `component:ProgressBar/DEC-1`; `spec:AST-002` |
| FR3 | The root holds a label row and a track. The label names the track (`role="progressbar"`) through `aria-labelledby`; `isLabelHidden` hides it visually only. When determinate, the track exposes `aria-valuenow` (the clamped value), `aria-valuemin` 0, `aria-valuemax` (`max`), and `aria-valuetext` from `formatValueLabel`, which defaults to the rounded percentage; `hasValueLabel` shows the same text beside the label. A zero or non-finite `max` gives 0% and never renders `NaN`. Astryx-owned value text comes from one catalog message (`architecture:internationalization` INV1a). Implementation gap: the default text is assembled in code (Current-state impact). | Current source and tests |
| FR4 | The track is 8px high, fully rounded, in `--color-background-muted`, and clips the fill. The determinate fill spans the track and slides along it so its leading edge sits at the clamped percentage, mirrored by the bar's own writing direction, moving over `--duration-medium` when the value changes; under reduced motion a new value lands at once on every pointer (`design:reduced-motion` DR1). The fill paints `--color-accent`, `--color-success`, `--color-warning`, or `--color-error`, or `--color-text-disabled` for `neutral` and disabled bars; a theme-added variant has a safe theme-independent fill (`architecture:component-theming-surface` INV14). Implementation gap: a theme-added variant paints no fill or mark color until a theme styles it (Current-state impact). In forced colors the fill is sized to the value instead of sliding and keeps a 1px `CanvasText` outline so its shape stays visible (`architecture:theme-tokens` INV11). | Current source and tests |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: marks on the bare track still paint --color-text-primary for a theme-added variant (ProgressBar.tsx:634-636); only the fill and on-fill marks lose colour. "no fill and no on-fill mark color" would be exact.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on an engineering-owner decision: this head makes component:ProgressBar current, including DEC-1 and FR1–FR9, and no current record states those requirements yet. An engineering owner records the decision by commenting /approve-spec with the full SHA of the head they approve, and the author can do this as an engineering owner. The three record lines flagged on the earlier head are fixed; two optional wording fixes are cheapest to fold in before an approval binds the text:

  • Representative states, With marks: "labelled by tooltip" reads as the description-only path that FR7 calls a gap; say the tooltip shows each label (FR7).
  • FR4: cite design:reduced-motion DR2/DEC-3 for "lands at once on every pointer", since DR1 only forbids travel.

[Automated review]

| With value text | The graphic still satisfies FR1; the text supplements it. | Formatter. |
| Label hidden | The bar keeps its accessible name. | — |
| Indeterminate | No value attributes; sliding fill; slower under reduced motion (OQ2). | Variant. |
| With marks | Ticks outside the `progressbar`, labelled by tooltip. | Mark labels. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"labelled by tooltip" reads as the description-only path FR7 calls a gap (FR7 requires the label as the mark's accessible name). Suggest: "Ticks outside the progressbar; the tooltip shows each label (FR7)."

| FR1 | Every determinate presentation ProgressBar provides is a sufficient standalone visual for distinguishing completed from remaining progress; external visible text may supplement it but is never required, and supplementary text or graphics never substitute for that distinction. ProgressBar does not make visual correctness depend on caller-declared external content it cannot verify or associate. | `component:ProgressBar/DEC-1`; `spec:AST-002` |
| FR2 | New public API that adjusts the visual distinction is considered only after the component has a correct base behavior and a stable caller-owned distinction still remains. | `component:ProgressBar/DEC-1`; `spec:AST-002` |
| FR3 | The root holds a label row and a track. The label names the track (`role="progressbar"`) through `aria-labelledby`; `isLabelHidden` hides it visually only. When determinate, the track exposes `aria-valuenow` (the clamped value), `aria-valuemin` 0, `aria-valuemax` (`max`), and `aria-valuetext` from `formatValueLabel`, which defaults to the rounded percentage; `hasValueLabel` shows the same text beside the label. A zero or non-finite `max` gives 0% and never renders `NaN`. Astryx-owned value text comes from one catalog message (`architecture:internationalization` INV1a). Implementation gap: the default text is assembled in code (Current-state impact). | Current source and tests |
| FR4 | The track is 8px high, fully rounded, in `--color-background-muted`, and clips the fill. The determinate fill spans the track and slides along it so its leading edge sits at the clamped percentage, mirrored by the bar's own writing direction, moving over `--duration-medium` when the value changes; under reduced motion a new value lands at once on every pointer (`design:reduced-motion` DR1). The fill paints `--color-accent`, `--color-success`, `--color-warning`, or `--color-error`, or `--color-text-disabled` for `neutral` and disabled bars; a theme-added variant has a safe theme-independent fill (`architecture:component-theming-surface` INV14). Implementation gap: a theme-added variant paints no fill and no on-fill mark color until a theme styles it (Current-state impact). In forced colors the fill is sized to the value instead of sliding and keeps a 1px `CanvasText` outline so its shape stays visible (`architecture:theme-tokens` INV11). | Current source and tests |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: DR1 only forbids travel; the at-once rule for an in-place value on every pointer is design:reduced-motion DR2 and DEC-3 (its verification row names a determinate ProgressBar's fill). Suggest citing DR2/DEC-3 here.

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 2472aec

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026
@cixzhang
cixzhang merged commit 0d71e8d into main Oct 31, 2026
51 checks passed

This branch was successfully deployed

1 active deployment
Preview — 2472aecf Deployed Oct 31, 2026 by vercel[bot]
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

Development

Successfully merging this pull request may close these issues.

1 participant