Skip to content

Commit 6b2f23d

Browse files
docs(agents): reconcile instruction files with ADRs 0003-0009
PR #1014 landed seven Accepted ADRs that overlap these files. Cross-checked all eleven against all seven. Fixed a self-contradiction of my own making. versioning.md claimed "Every component" emits a Global<Name>CssSelector while packages/components/AGENTS.md said 14 ship none — and both then claimed a set of "14" with different definitions, because two validators reported different facts and I conflated them. Ground truth: 102 of 116 files export a selector, 13 of those have no styles, so 89 emit both. Both files now agree. Aligned with ADR-0004 on token scope. component-css.md called the calendar and date-picker overrides of --hop-comp-button-* "the design"; ADR-0004 calls those same three modules known debt. The family read boundary stays (five families have no owning component) but the debt is now named as debt. A drafting slip in the same file asserted the opposite of its own hard rule and is gone. Carried the ADR carve-outs the rules contradicted: there is no border-width token family, so the 21 hairline `0.0625rem` literals are expected; motion has core tokens but no semantic tier, so --hop-easing-* is referenced directly; and there is no global --hop-focus-ring token, so focus rings go through a local custom property. Without these an agent would try to tokenize values that have no token to reach for. Carried rules no instruction file held. ADR-0006 had zero coverage despite root AGENTS.md routing "defaults" to component-architecture.md, so controlled and uncontrolled pairs, useControlledState, and the silent mixed-mode bug now appear there. Five of six rows of ADR-0007's prop-naming table were carried nowhere — typescript.md governs file names, not prop names — so isOpen over open, onPress over onClick, elementType over as, and the ref conventions are now in component-architecture.md. ADR-0008's three imperatives land in versioning.md: assert the exported selector in tests rather than the hashed ident, never read a --hop-* variable outside a provider subtree, and leave BodyStyleProvider to bridge <body>. package-json.md now names the banned headless and CSS-in-JS libraries and the react-aria peer-versus-runtime split. packages/icons/AGENTS.md records the accessibility invariant its generators produce, which an agent could otherwise delete silently. Added an ADR routing table rather than copying the rest. The ADRs own the rationale and mechanism; these files own the imperative. That keeps one source of truth and makes 0003-0009 reachable, which nothing made them before — no instruction file referenced any of them. Also: Chromatic is not an accessibility signal, contrast is excluded from the axe pass, and test-storybook is not in CI, so nothing about accessibility gates a PR. Recorded so it is not cited as evidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ef024ff commit 6b2f23d

8 files changed

Lines changed: 156 additions & 59 deletions

File tree

.claude/rules/component-css.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,41 @@ paths:
55

66
# Component CSS
77

8+
[ADR 0004](../../docs/adr/0004-design-tokens-are-the-only-source-of-visual-values.md) and
9+
[ADR 0005](../../docs/adr/0005-styling-uses-style-props-and-native-css.md) own the reasoning.
10+
811
## Hard Rules
912

1013
| Rule | Violation |
1114
| ---- | --------- |
15+
| Resolve every colour to a **semantic** token | A hex, `rgb()`, `hsl()`, or named colour |
1216
| Resolve a local declaration to a token, not a raw length | `--hop-MenuItem-sm-padding-block: 0.625rem` instead of a `--hop-space-*` token |
17+
| Prefer a semantic token over a core one — component CSS references no core colour today | Reaching past `--hop-neutral-text` to a core palette entry like `--hop-coastal-25` |
1318
| Read `--hop-comp-<family>-*` only from a module in that family | `list-box/src/ListBoxItem.module.css` reading `--hop-comp-select-*` |
19+
| Redefine a token-package property only inside `packages/tokens` | Overriding `--hop-comp-button-*` from another component's module |
1420
| Locate a token file by grepping its `comp-` key, not by component name | Expecting `checkbox.tokens.json`; the file is `mark.checkbox.tokens.json` |
1521
| Declare a local on the module root when the component has no token file | Adding a 22nd token file for a one-off value |
1622

17-
Stylelint already owns the mechanical layer: `px` is outside `unit-allowed-list`, and
18-
`selector-class-pattern` enforces `hop-ComponentName__element-name--modifier-name`. Both fail at lint, so the
19-
live decisions are the ones above.
23+
Stylelint owns the mechanical layer — `px` is outside `unit-allowed-list`, `selector-class-pattern`
24+
enforces `hop-ComponentName__element-name--modifier-name`, and `custom-property-pattern` enforces
25+
`hop-ComponentName-*`. Because `px` already fails at lint, the live failure mode is a `rem` literal
26+
that lint permits; converting `8px` to `0.5rem` is not tokenizing it.
27+
28+
## Sanctioned raw values
29+
30+
| Case | Why |
31+
| ---- | --- |
32+
| Hairlines as `0.0625rem` — 21 in component CSS | There is no border-width token family |
33+
| `--hop-easing-*` referenced directly | Motion has core tokens but no semantic tier |
34+
| Focus rings | There is no global `--hop-focus-ring` token. Set `outline: none` in the base rule and restore it under `[data-focus-visible]`, through a local `--hop-<Component>-focus-ring-color` pointing at `--hop-primary-border-focus` or the family's `--hop-comp-*-border-color-focus` |
35+
| Breakpoint values | Deliberately not tokens — custom properties cannot be used in media query conditions. The scale lives in `packages/styled-system/src/responsive/Breakpoints.ts` |
2036

21-
## Token families are shared, not per-component
37+
## Token families are shared
2238

2339
Only 21 token files exist per brand against 90 CSS modules, and five families have no single owning
24-
component — `field` (read by 12 modules), `mark`, `control`, `select`, `tabs`. `comp-button` is read
25-
by 5 modules. Cross-family reads are the design, so the boundary is the *family*, not the file.
40+
component — `field` (read by 12 modules), `mark`, `control`, `select`, `tabs`. So the read boundary is
41+
the *family*, not the file. Reading across families is the violation. The three modules in `calendar/`
42+
and `date-picker/` that override `--hop-comp-button-*` are known debt, not a pattern to copy.
2643

2744
## The three layers
2845

@@ -36,7 +53,7 @@ by 5 modules. Cross-family reads are the design, so the boundary is the *family*
3653
| Layer | Defined in | Read from |
3754
| ----- | ---------- | --------- |
3855
| `--hop-comp-<family>-*` | `packages/tokens/src/tokens/components/<brand>/*.tokens.json` | Modules in that family |
39-
| `--hop-<category>-*` | `packages/tokens/src/tokens/core/`, `.../semantic/<brand>/<light\|dark>/` | Any module |
56+
| `--hop-<category>-*` | `packages/tokens/src/tokens/core/`, `.../semantic/<brand>/<light\|dark>/` | Any module, semantic tier first |
4057
| `--hop-<PascalName>-*` | The module's own root selector | That module — unless deliberately published as a theming hook, as `--hop-RichIcon-*` is for `packages/icons` |
4158

4259
Sibling modules inherit wholesale with `composes: hop-Input from "../../inputs/src/Input.module.css"`,

.claude/rules/component-tsx.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ paths:
1010
| Rule | Violation |
1111
| ---- | --------- |
1212
| Pass `className` and semantic props (`size`, `variant`, `color`, `slot`, `isHidden`) through a context object; leave raw styled-system CSS props out | `[HeadingContext, { fontWeight: "…", padding: "…" }]` |
13+
| Reach for the semantic element or the React Aria component | A `div` with `onClick` and `role="button"`, which is not equivalent |
14+
| Give an icon-only control an accessible name, and guard it at runtime as `Button`, `ToggleButton`, `Tabs` and `RichIconAvatarImage` do | An icon-only control types accept but screen readers cannot name |
15+
| Leave `tabIndex` at 0 or -1 | `tabIndex={1}` — the repo is currently fully compliant |
16+
| Express visual values through tokens, not a hand-written inline `style` object | A literal `style={{ padding: 8 }}` |
1317
| Read browser globals inside an effect, a memo, or behind `useIsSSR()` | `window.matchMedia(…)` in a component body, as `SegmentedControlItem.tsx:63` still does |
1418
| Take `useId` from `react-aria` | `import { useId } from "react"` |
1519
| Export each component in its own statement | `export { _ComboBox as ComboBox, ListBoxItem as ComboBoxItem }` |
@@ -23,6 +27,9 @@ export const ComboBoxItem = ListBoxItem;
2327
export { _ComboBox as ComboBox };
2428
```
2529

30+
Prefer a React Aria Component over its hooks; drop to the hooks only when the component API is too
31+
rigid, and record why, as `TooltipTrigger` does in a code comment.
32+
2633
## Server rendering
2734

2835
71 of 116 components have a `tests/vitest/<Name>.ssr.test.tsx`. Add one with every new component:

.claude/rules/package-json.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,28 @@ is downloaded by every end user — including those on low-bandwidth connections
1414
| ---- | --------- |
1515
| Reach for React, react-aria, TypeScript or CSS before adding a runtime dependency | Adding `dayjs` or `date-fns` for one format call |
1616
| Write a small local utility when the need is narrow | Depending on a utility library for one function |
17+
| Keep React Aria as the only headless primitive library | Adding Radix, Headless UI or Ark |
18+
| Keep styling in style props and native CSS | Adding `styled-components`, `emotion`, `stitches` or Tailwind |
1719
| List a package under `dependencies` only when consumers load it at runtime, or when its types are part of the published API | A build-only plugin in `dependencies` |
20+
| Keep `react-aria` and `react-aria-components` as peer dependencies; `@react-aria/*` and `@react-stately/*` are direct runtime deps | Promoting a peer to a runtime dependency |
1821
| Declare a package's own build and type tooling in that package | Putting `rslib` or `@types/react` only at the root |
1922
| Leave the repo-wide runners at the root and invoke them from there | Adding `vitest` or `stylelint` to a package |
23+
| Move the React Aria versions with `pnpm update-react-aria-deps` | Bumping one `@react-aria/*` package by hand |
2024
| Keep a shared dependency on one version across the workspace; `pnpm syncpack` is the check | Two packages pinned to different react-aria versions |
2125

26+
Two lockfile entries look like violations and are not: `@stitches/core` arrives transitively through
27+
Sandpack, and `SizingMapping` borrows Tailwind's fraction scale as a value table, not an adoption.
28+
2229
## What is already allowed
2330

2431
The react-aria ecosystem is the existing stack, not a new dependency — `@internationalized/date`,
2532
`@react-aria/*`, `@react-stately/*` and `@react-types/shared` are all in scope. Two more are
2633
grandfathered and need no justification:
2734

28-
| Package | Why it stays |
29-
| -------- | ------------------------------------------------------------------------- |
30-
| `clsx` | Used at 78 sites across components, icons and styled-system |
31-
| `csstype`| Types-only, but they are part of styled-system's published API surface |
35+
| Package | Why it stays |
36+
| ------- | ------------ |
37+
| `clsx` | Used at 78 sites across components, icons and styled-system |
38+
| `csstype` | Types-only, but they are part of styled-system's published API surface |
3239

3340
`svg-icons` and `tokens` have zero runtime dependencies. Keep it that way.
3441

AGENTS.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ Claude Code attaches the rules below itself when it reads a matching file. Every
3131
| Any exported name, CSS class name, or `--hop-*` custom property | `docs/agents/versioning.md` |
3232
| Naming a new `.ts`/`.tsx` file or a new component directory | `docs/agents/typescript.md` |
3333

34+
### Architecture decisions (MUST follow)
35+
36+
Accepted decision records. They own the rationale and the mechanism; the instruction files above own the imperative.
37+
38+
| Trigger | Read |
39+
| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
40+
| A new interactive component, or hand-rolling ARIA, focus or keyboard behavior | [ADR 0003](docs/adr/0003-react-aria-is-the-primitive-foundation.md) |
41+
| A hardcoded colour or length, or adding a token | [ADR 0004](docs/adr/0004-design-tokens-are-the-only-source-of-visual-values.md) |
42+
| Style props, `UNSAFE_*`, or reaching for a CSS module | [ADR 0005](docs/adr/0005-styling-uses-style-props-and-native-css.md) |
43+
| A stateful prop, a `default*` prop, or a controlled/uncontrolled pair | [ADR 0006](docs/adr/0006-components-support-controlled-and-uncontrolled-modes.md) |
44+
| Naming a prop, an event handler, or a ref | [ADR 0007](docs/adr/0007-component-api-naming-conventions.md) |
45+
| A class name, a token declaration, or a breaking change | [ADR 0008](docs/adr/0008-versioning-for-parallel-releases.md) |
46+
| Keyboard behavior, focus rings, or an accessible name | [ADR 0009](docs/adr/0009-accessibility-baseline.md) |
47+
3448
## Hard Rules (Non-Negotiable)
3549

3650
| Rule | Violation |
@@ -102,5 +116,5 @@ These three encode the workflows of this repository. The installed third-party s
102116

103117
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — monorepo setup, installation, and the full command list. Packages must be built before anything runs.
104118
- **[contributing/](contributing/)** — human-facing guides for [tokens](contributing/tokens.md), [icons](contributing/icons.md) and [components](contributing/components.md).
105-
- **[docs/adr/](docs/adr/)** — architectural decisions. Consult existing ADRs before proposing a change that contradicts one; note ADR 0002 is still `Proposed`.
119+
- **[docs/adr/](docs/adr/)**nine architectural decision records; 0003 through 0009 are indexed above. ADR 0002 is the only one still `Proposed`.
106120
- **[apps/docs/ai-pipeline/CONTRIBUTING.md](apps/docs/ai-pipeline/CONTRIBUTING.md)** — how a content edit reaches the documentation site, the MCP server, and the published Hopper agent Skill. See also [ADR 0002](docs/adr/0002-hopper-agent-skill.md).
Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,80 @@
11
# Component Architecture
22

3+
[ADR 0003](../adr/0003-react-aria-is-the-primitive-foundation.md),
4+
[ADR 0006](../adr/0006-components-support-controlled-and-uncontrolled-modes.md) and
5+
[ADR 0007](../adr/0007-component-api-naming-conventions.md) own the reasoning behind this file.
6+
37
## Hard Rules
48

59
| Rule | Violation |
610
| ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
11+
| Start a new interactive component from React Aria Components or its hooks | A `div` with an `onClick` and a `role` |
12+
| Let React Aria own focus traps, roving tabindex, type-ahead and ARIA wiring | Hand-rolling any of them |
713
| Expose styling props, an appendable `className` and a `ref` on the root element | Skipping `useStyledSystem` in a new component |
814
| Name a prop bag after the child it lands on — `<child>Props` | A bespoke `extraProps` for the inner input |
915
| Reuse the shared placeholders for slot content | A component-specific `CardHeader` instead of `Header` |
1016
| Ship brand defaults so a consumer sets as few props as possible | A required `variant` prop |
1117
| Set a child's appearance from the parent through `SlotProvider` and the child's context | Leaving the consumer to style a composed child |
18+
| Expose `default*` alongside the controlled prop on a stateful component | A controlled-only prop pair on something new |
19+
| Route state through `useControlledState`, or the React Aria state hook that wraps it | Hand-writing the controlled/uncontrolled branch |
1220
| Leave `stopPropagation` alone; with react-aria's `useKeyboard`, call `event.continuePropagation()` for keys you do not handle | Swallowing `Escape` without meaning to |
1321

22+
`InputGroup` calls `preventDefault` deliberately, to forward focus to its inner input — the one
23+
sanctioned exception. `ActionBar` is the only `useKeyboard` site, and the only controlled-only
24+
component. Mixing modes on one prop is a silent bug: the controlled prop wins and `default*` is
25+
ignored, with no React warning, because React never sees these props.
26+
27+
## Naming
28+
29+
| Kind | Convention | Never |
30+
| -------------------------- | ------------------------------------------ | ----------------------------- |
31+
| Boolean | `isOpen`, `isDisabled`, `isFluid` | `open`, `disabled` |
32+
| Event handler | `onPress`, `onChange`, `onSelectionChange` | `onClick` |
33+
| Uncontrolled initial value | `defaultOpen`, `defaultValue` | `initialOpen` |
34+
| Element swap | `elementType` | `as`, `component`, `renderAs` |
35+
| Outer element ref | `ref` | `rootRef`, `containerRef` |
36+
37+
`as` exists on `Box` alone; named inner refs (`inputRef`, `inputStartRef`) are deliberate where there
38+
are several focusable targets. `onPress` receives a React Aria `PressEvent`, not a native event.
39+
40+
Read prop names from `packages/components/src/<group>/src/<Component>.tsx` — TypeScript is
41+
authoritative. The generated API JSON under `apps/docs/dist/ai-docs/` is a build artifact and is wrong
42+
for `Select`, `MultiSelect` and `ComboBox`.
43+
44+
## Styling escalation
45+
46+
Style props first, then an `UNSAFE_*` prop, then a CSS module. `UNSAFE_*` is a whitelist of specific
47+
props, not a universal prefix — see [ADR 0005](../adr/0005-styling-uses-style-props-and-native-css.md)
48+
before reaching for it. `UNSAFE_className` and `UNSAFE_style` do not exist.
49+
50+
## Composition
51+
1452
The shared placeholders are `Header` (`header/`), `Content` and `Footer` (`layout/`), and `Text`
15-
(`typography/text/`), reused through their contexts in 27, 8, 8 and 3 files. `DisclosureHeader` and
16-
`CalendarHeader` are the sanctioned exceptions — a header carrying its own interactive behavior.
53+
(`typography/text/`). `DisclosureHeader` and `CalendarHeader` are the sanctioned exceptions — a header
54+
carrying its own interactive behavior. A wrapper is a normal composition unit (`CheckboxField`,
55+
`PopoverTrigger`); what to avoid is a wrapper whose only purpose is renaming props.
1756

1857
`Callout` is the worked example for parent-driven appearance: it wraps children in a `SlotProvider`
19-
that sets `ButtonContext` and `LinkButtonContext` to `variant: "secondary"`. `Popover`, `Accordion`,
58+
setting `ButtonContext` and `LinkButtonContext` to `variant: "secondary"`. `Popover`, `Accordion`,
2059
`Modal`, `ComboBox` and `Select` do the same. `Card` does **not** — it wires no context at all, so
2160
treat it as a gap rather than a pattern to copy.
2261

2362
## Goals for new API surface
2463

25-
These hold for anything new. Existing components diverge, so do not "fix" the named cases several
26-
are locked in by public types and would be breaking changes.
64+
These hold for anything new. Existing components diverge; do not "fix" the named cases, as several are
65+
locked in by public types and would be breaking changes.
2766

28-
| Goal | Where the codebase diverges |
29-
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
30-
| Give a wrapper or nested element a prop bag and a `ref` | 8 components render an internal wrapper exposing nothing, incl. `Popover`, `Radio`, `Checkbox`, `Tile`, `ListBoxItem` |
31-
| Give every native and react-aria event a callback prop | `MenuTrigger` wires an internal `onPressStart` a consumer cannot observe |
32-
| Forward the original event arguments to the consumer's handler | `Alert`'s three `on*ButtonClick` props and `TextField.onClear` are typed `() => void` |
33-
| Take rendered content as children in a slot, not as a prop | 18 shipped `ReactNode` props, incl. `prefix`, `footer`, `icon`, `description` |
67+
| Goal | Where the codebase diverges |
68+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
69+
| Give a wrapper or nested element a prop bag and a `ref` | 8 components render an internal wrapper exposing nothing, incl. `Popover`, `Radio`, `Tile` |
70+
| Give every native and react-aria event a callback prop | `MenuTrigger` wires an internal `onPressStart` a consumer cannot observe |
71+
| Forward the original event arguments to the consumer's handler | `Alert`'s three `on*ButtonClick` props and `TextField.onClear` are typed `() => void` |
72+
| Take rendered content as children in a slot, not as a prop | 18 shipped `ReactNode` props, incl. `prefix`, `footer`, `icon` |
3473

3574
## Mobile
3675

3776
Where the native mobile experience diverges sharply from the web one, add a sibling built on the
38-
native element to `packages/styled-system/src/html-wrappers/html.ts`, rather than emulating native
39-
behavior inside the richer component. A name that collides with a Hopper component takes an `Html`
40-
prefix — `HtmlButton`, `HtmlHeader` — so a native select would be `HtmlSelect`. None exists yet.
77+
native element to `packages/styled-system/src/html-wrappers/html.ts`. A name colliding with a Hopper
78+
component takes an `Html` prefix — so a native select would be `HtmlSelect`. None exists yet.
4179

4280
`packages/components/src/html-elements/` is documentation previews only; nothing there is exported.

0 commit comments

Comments
 (0)