Skip to content

Latest commit

 

History

History
228 lines (160 loc) · 28.5 KB

AGENTS.md

File metadata and controls

228 lines (160 loc) · 28.5 KB

AI Agent Reference — Cube UI Kit

Entry point for AI agents working on @cube-dev/ui-kit.

Maintenance note: The design-system reference (tokens, presets, colors, modifiers, state syntax, form system, icons) lives in src/stories/Usage.docs.mdx (Storybook → Getting Started / Usage). The component creation guide lives in src/stories/CreateComponent.docs.mdx (Getting Started / Create Component). Update these whenever you add components, change the API surface, or modify tokens/presets.

Before You Start

Run this at the start of every task, before reading code, running tests, or trusting any type error:

pnpm install && pnpm rebuild esbuild

A working copy can sit idle across dependency bumps, so node_modules may not match pnpm-lock.yaml. A stale tree does not fail loudly — it silently inverts results. Tests pass locally and fail on CI (or the reverse), and tsc reports errors that do not exist on the pinned version. Anything you conclude from a stale tree is unreliable, including the conclusion that a failure is "pre-existing".

When local results and CI disagree, suspect the dependency tree first. Compare the installed version against the lockfile before theorising about anything else:

pnpm list @tenphi/tasty @tenphi/glaze

Rules

Project-specific working rules for AI agents. Not published with the package.

  • react-compiler.md — React 19, callbacks, Effect Events, memoization and lifecycle ownership
  • coding.md — development flow, code style, import rules, inline style and Tasty lint rules, Markdown formatting, knowledge maintenance
  • entropy.md — proportional complexity in code, public APIs, consumer usage and UX; Entropy review and developer exceptions
  • input-components.md — form-attachable input components (useFieldProps, validation props, wrapWithField)
  • storybook.md — .stories.tsx and .docs.mdx authoring, including the play-function rule below
  • documentation.md — .docs.mdx structure + update flow
  • tests.md — Vitest + React Testing Library patterns
  • probe.md — pnpm probe: inspecting the HTML, CSS and tokens a render actually produces
  • screen-reader.md — the virtual screen reader: pnpm probe render --sr and the spec helpers, with the limits that make a silence the simulator's rather than the component's
  • commit-changes.md — commit message convention
  • eslint-plugin.md — the shipped lint rule + the defaults registry it checks against
  • review.md — shared UI Kit review checklist, scope and verification; the local ui-review skill reads this file

Entropy rule

Every task follows the Entropy rule: added complexity must serve validated requirements and remain proportional to the capability delivered. Prefer safe simplification in the affected area, and consider the burden on component maintainers, consumers and end users together. Record any developer-accepted exception with its reason and scope. Every UI Kit review includes an Entropy review and reports the entropy change level in the affected context.

Changesets

When making code changes that affect end users or the public API, always add a new changeset or update an existing one in .changeset/ as part of the same task. Do not wait to be asked.

  • Prefer updating an existing open changeset that already covers the same work/PR; otherwise add a new .md file.
  • Use patch for bug fixes and small changes; minor for new features and noticeable breaking changes.
  • Keep the summary concise and user-focused ("@cube-dev/ui-kit": patch|minor frontmatter).
  • Skip changesets for docs-only, test-only, Storybook-only, or internal tooling that does not affect package consumers. Also skip fixes for issues introduced and resolved within the same PR.
  • Add changeset manually (no CLI) — full guidelines: .cursor/commands/add-changeset.md.
  • Write the summary unwrapped: one paragraph is one line, like every other Markdown file here (see Markdown rules).

Stories: Interaction-Only States

A state that only exists during an interaction — an open tooltip, a hover or focus style, an expanded overlay — is invisible to Chromatic unless a play function puts the story into it. Chromatic runs play before it snapshots, so a story whose point is such a state must drive it with play and end on a waitFor assertion for the state itself (that assertion is also the wait Chromatic needs). Drive one element per story — only the final state is captured. For tooltips, copy the recipe verbatim: timeout(250) (the trigger is wired in a mount effect), unhover before hover (React Aria ignores a hover until a mouse move sets the pointer modality), and delay: 0 in the tooltip config. Both caveats fail silently, and a local render test can pass while the story does not — Chromatic is the check. Full pattern: storybook.md.

Imports: Reach Past the Barrel

Inside src/, import the file that defines a thing rather than the barrel that re-exports it: from '../../actions/ItemButton/ItemButton', not from '../../actions'. Same component either way, but the barrel also drags Menu, CommandMenu, ButtonSplit and everything they import into the graph. Three things read that graph — Chromatic's TurboSnap (which cannot scope a build around anything reachable from <Root>, so a barrel there costs a full 1000-snapshot rebuild), consumers' tree-shaking, and module init order around Root's module-scope configure(). Icons are enforced by no-restricted-imports; the rest is on you, with pnpm chromatic:check as the backstop and node scripts/chromatic-report.mjs --trace <file> to price a change before making it. index.ts files are exempt — assembling the public surface is their job. Full rules: coding.md.

React Compiler: Skip Manual Memoization

The build compiles runtime source with React Compiler 1.0.0 targeting React 19, using the native react/compiler-runtime. Consumers require React and React DOM 19.3 or newer. Write plain derived values and ordinary handlers; use local useEffectEvent callbacks for effect-owned work and retain useEvent only for a demonstrated retained-reference contract. Manual memoization does not own a permanent per-mount lifetime. Follow the canonical React Compiler and callback rules.

Run pnpm diagnostics:compiler and pnpm diagnostics:hooks --check after touching runtime source. Both ratchets reject regressions; every module adopting Effect Events also passes strict official hook/dependency rules without inline suppressions. Ratchet only verified improvements and review the baseline diff. Keep compiled and uncompiled behavior correct; preserve legacy Form opt-outs.

Styling: Keep Components Customizable

A consumer customizes an element by passing styles (or style props) to it, and every styling fix must leave that working. The trap is a parent fixing a child's layout from outside, with any of these:

  • a sub-element selector aimed at an element the parent does not render, such as $: '> form' or $: '> form > :not(:last-child)';
  • a blanket reset on another component's children, such as margin: 0 on every child;
  • a selector built to outrank another component's rule, using an extra type selector, a doubled class or !important.

Such a rule only works by beating the child's own styles, so it also beats the consumer's styles on that element, and their padding, gap or layout override silently stops applying. Fix the element in the component that owns it:

  • The child owns its context, as a named state. When a component must lay out differently inside another, declare a local state at the top of its own styles and use that name in the maps:
    '@in-dialog': '@parent(id=Dialog, >)', // `, >` = direct parent; drop it for any ancestor
    display: { '': 'block', '@in-dialog': 'flex' },
    The rule keeps the child's own specificity and a consumer's styles key replaces it. Because the state travels with the styles, a consumer can also target the same case: gap: { '': '3x', '@in-dialog': '1x' }. Worked example: FormElement in Form.tsx. It replaced two Dialog rules that silently beat a form's own display and gap styles.
  • Never build state keys in JS. No const IN_DIALOG = '@parent(…)' spread as [IN_DIALOG]: …. The condition then lives outside the styles, a consumer can't name it, and every map carries the raw selector instead of a word. If several components need a state, register it with the global predefined states in Root.tsx (setGlobalPredefinedStates) rather than exporting a string.
  • A parent passes values only through channels built for overriding. Those channels are slot styles (a SlotProvider merges them under the consumer's own styles), custom properties the child reads ($dialog-padding-h), props and mods.
  • Know what tasty's gap compiles to. On display: flex or grid it becomes the CSS gap property. On display: block it becomes a bottom margin on every child but the last. Those margins land on whatever goes inside the container, and a gap: 0 from outside never reaches them. So when a block container's spacing is wrong in some context, change the container's own gap for that context; don't reset its children. Form stays a block outside dialogs because a flex column would stretch a bare Button child to full width and stack inline content.
  • Check before adding a rule. Ask whether a consumer can still override it with ordinary styles on the element it affects. If their styles would lose, the rule is a hack: move the fix to the component that owns the element, and add a browser test that a consumer override still applies.

Never start a :has() with ~ (:has(~ X)). jsdom before 27 matches selectors with nwsapi, which throws on it for any element without a parent. jsdom's getComputedStyle walks up to <html>, so once such a rule is on the page, every role query, toBeVisible and user-event click in a consumer's tests throws. Cube Cloud runs jsdom 26. This repo runs 29, so its tests never show it. Use :last-child, :first-child or a mod. 0.187.0 shipped one on ListBox options; ListBox.test.tsx guards them.

Stories: The Snapshot Budget

Chromatic bills per snapshot and a snapshot is one story, so every story is a recurring cost for as long as it exists. A story earns its snapshot by showing something no other story shows — and a surprising number do not: controlled/uncontrolled twins, props that only paint during a drag or hover, DynamicSections next to WithSections, a Modal story for a dialog whose default type is already modal. Keep those stories (the sidebar and the docs page still want them) and opt the photograph out with NO_SNAPSHOT from src/stories/chromatic.ts, with a comment saying why. Where several stories differ only in one enum value, one matrix story is both cheaper and a better review artefact — an inconsistency between two themes shows up in one image and not across two.

Do not guess which stories are redundant, measure: pnpm build-storybook && pnpm chromatic:duplicates renders every story in real Chromium (waiting for play, as Chromatic does) and reports the groups that are provably paying twice for one image, plus the stories named for an overlay that never opened. pnpm chromatic:report covers the other half of the bill — how much of the suite one changed file drags in, and which files force a full rebuild by being reachable from .storybook/preview.jsx. Inside that dependency tree, import defining files directly rather than through a barrel: one from '../../actions' pulls most of the library into the always-rebuild set. pnpm chromatic:check gates this in CI. Full guidance: storybook.md.

Inspecting Rendered HTML & CSS — pnpm probe

Answering "what CSS does this actually produce?" is one call, not a throwaway vitest spec: pnpm probe styles '{"fill":"#purple"}' (also tokens, render, globals). It loads the real config — the configure() and setGlobalPredefinedStates() from src/components/Root.tsx and the live Glaze palette. Never verify a style through node -e "import {renderStyles} from '@tenphi/tasty'" — that path loads none of it, so units, recipes, presets and tokens go unresolved and the output looks authoritative while being wrong.

Use it as freely as you like, either tier: it is a throwaway question-answering tool, so nothing here needs justifying. It is also the honest way to check a styling claim before you write it into a doc or a review. pnpm probe:browser adds the four things jsdom cannot do — computed values, geometry, pointer behaviour, screenshots — at the cost of a browser binary and a slower start.

Read docs/rules/probe.md for the modes, the jsdom blind spots that make an answer jsdom's rather than the truth, the one-time pnpm exec playwright install chromium, and how the *.browser.test.tsx suite differs (that one is in CI, and the probe's freedom does not extend to it).

The DOM helpers behind it live in src/probe/ and ship as the @cube-dev/ui-kit/probe entry, which Cube Cloud's own yarn probe imports. Changing them is a change to published API.

Hearing What a Screen Reader Says

"What does a screen reader announce here?" is one call: pnpm probe render --sr, on either tier. It reads the page with @guidepup/virtual-screen-reader and prints each phrase — button, Save, checkbox, Accept, not checked, polite: Added two. A snippet that exports play({ reader, screen, userEvent, waitFor, within }) drives keys and focus first, and --sr then prints what was said during play as well. Use it before you claim or fix anything about names, descriptions, states, reading order, modal containment or announcements.

In specs, use startScreenReader() / readWithScreenReader() from src/test/screen-reader.ts, imported by path. Never call the package's virtual.start() directly: it is a singleton whose unstopped sessions keep writing into later specs' logs. It is a simulator with blind spots — an inserted role="alert" is silent, and press() re-focuses the element under its cursor — so read docs/rules/screen-reader.md before trusting a silence, and confirm a new pattern with VoiceOver or NVDA.

Project Structure

src/
├── components/        # actions, content, fields, form, layout, navigation,
│                      # organisms, overlays, status, helpers, portal, other, shared
├── icons/             # 130+ icon components
├── shared/            # Form types (FieldBaseProps, FormBaseProps, FieldCoreProps)
├── tokens/            # Design tokens (colors, typography, spacing, sizes, shadows, layout)
├── stories/           # Storybook guides and documentation pages
├── _internal/         # Internal hooks (useEvent, etc.)
├── tasty-augment.d.ts # TypeScript module augmentation for tasty
└── index.ts           # Public barrel export

Each component lives in src/components/{category}/{ComponentName}/ and ships ComponentName.tsx, .stories.tsx, .docs.mdx, .test.tsx, and index.tsx.

Commands

  • pnpm storybook — start Storybook on port 6060
  • pnpm build — build library (tsdown, unbundled ESM)
  • pnpm test — run all tests (Vitest); add -- ComponentName to filter, -u to update snapshots
  • pnpm test:once — the full pnpm test (no test arguments), skipped when this exact tree is the last one that passed it in this worktree: a clean working tree (no changes, no untracked files, no tracked file written during that run) with the same Node, install and test environment (KEYED_ENV in the script; a test that reads another environment variable adds it there). It never selects tests by changed files. See scripts/test-once.mjs
  • pnpm test:browser — run the *.browser.test.tsx specs in real Chromium (layout, pointer, observers)
  • pnpm probe — print the HTML, CSS or tokens a snippet actually produces, with the real config loaded. pnpm probe:browser for computed values, geometry and screenshots; render --sr for what a screen reader says. See Inspecting Rendered HTML & CSS and Hearing What a Screen Reader Says
  • pnpm fix — lint + format (Oxlint + Prettier)
  • pnpm size — check bundle size limits
  • pnpm chromatic — visual regression
  • pnpm chromatic:report — snapshot inventory + TurboSnap blast radius. pnpm chromatic:check is the CI gate; pnpm chromatic:duplicates finds stories that photograph the same thing. All three read storybook-static, so run pnpm build-storybook first. See The Snapshot Budget
  • pnpm add-icons — add new icons from tabler
  • pnpm audit-docs — audit component API ↔ docs ↔ argTypes sync. Options: --component=Name, --fix-stories, --fix-docs, --json, --verbose, --all-props. Run after changing a component's API or adding a new component.
  • pnpm diagnostics:compiler — check every source file against the React Compiler coverage baseline (scripts/compiler/baseline.json); --update ratchets it after a reviewed change. See React Compiler
  • pnpm diagnostics:hooks --check — whole runtime-source official Hooks ratchet plus strict Effect Event adoption checks. --update tightens the canonical baseline after reviewed improvements. See React Compiler rules.
  • pnpm diagnostics:form — Form/input view of the same Hooks baseline; --check rejects growth. Update through diagnostics:hooks --update. See src/components/form/Form/legacy-contract/README.md
  • pnpm check:react-package — verify supported React peers and native compiler imports before publishing or testing a packed build.
  • pnpm check:consumer-declarations — after pnpm pack:test, install the packed build in a consumer and emit declarations for tasty(Component, {}) of every exported component with TypeScript 7. It fails when a declaration needs a type the package does not export (TS2883, TS4023), and when the package bundles dist/node_modules. Name a compound component with an exported interface, as LayoutComponent does.
  • pnpm diagnostics:complexity — every function in src/ ranked by cognitive complexity (SonarSource's metric, from eslint-plugin-sonarjs running in oxlint), worst first. Options: --limit N, --all, pnpm -s … --json. .oxlintrc.json caps every function with the same rule (sonarjs/cognitive-complexity), so pnpm lint fails CI if any function exceeds it. The ceiling only goes down: when a refactor leaves the worst function lower, lower the ceiling to match (the report says when). A new function too complex to fit is a sign to extract hooks, sub-components or pure helpers, not to raise the ceiling. One quirk of the plugin (4.2.2): a ternary branch that is itself a function (cond ? () => true : fn) adds a level of nesting to everything after it in the enclosing function, so hoist such a function to a named constant. See scripts/complexity-report.mjs
  • pnpm audit-defaults — regenerate the lint plugin's defaults registry (src/eslint-plugin/defaults.generated.ts). Run whenever you change a default prop value. pnpm test fails until the registry matches what the components actually render — see eslint-plugin.md.
  • pnpm run update-tasty / pnpm run update-glaze — bump and pin @tenphi/tasty or @tenphi/glaze to the latest version. Pass --version=X.Y.Z to pin a specific version.

Environment

  • Tasty's experimental type-aware JSX analysis is enabled only in this repo's .oxlintrc.json through settings.tasty.typeAwareJSX, using tsconfig.json and the installed TypeScript. It distinguishes semantic props such as drop ordering and Item content from style props. Unresolved or erased prop types keep the name heuristic and may still need individual ignores. Cold lint runs load the TypeScript project; the setting is not part of the published Tasty config or plugin presets, so consumers such as Cloud remain opt-in.
  • Two TypeScripts. pnpm test:types and the tsc half of pnpm test:types:consumer run TypeScript 7 (the typescript-native alias, ~7× faster). typescript stays 5.x because TypeScript 7 ships no compiler API, and tsdown's declaration build, audit-docs, check-declaration-bindings.mjs, the export parsing in check-consumer-declarations.mjs and the type-aware lint above all need it. Both packages ship a tsc binary, so a bare tsc may run either one; use the scripts. TypeScript 7 removed baseUrl, so import by relative path, never from 'src/…'.
  • Node 24, pinned in .nvmrc and used by every CI job including publish — OIDC trusted publishing requires npm ≥ 11.5.1, which Node 24 ships and Node 22 (npm 10.x) does not. pnpm ^10, pinned to pnpm@10.34.5 via packageManager; no Corepack involved. Note the published package still declares engines.node >=22.0.0 — that is the floor for consumers, not for building this repo.
  • After pnpm install, run pnpm rebuild esbuild (postinstall is blocked in pnpm-workspace.yaml). Do this at the start of every task — see Before You Start.
  • Husky hooks: pre-commit runs pnpm lint-staged; pre-push runs node scripts/test-once.mjs --pre-push, which skips like pnpm test:once only when every pushed ref also points at that tree, and otherwise runs the full suite on the working tree as before. So a pnpm test:once pass covers the next push of that tree. Skip only intentionally (--no-verify or HUSKY=0).
  • No external services or databases required for local development.

Tasty Documentation

Bundled in docs/tasty/ (symlinked from node_modules/@tenphi/tasty/docs in dev, copied at pack time by scripts/prepare-docs.mjs). Consult these when authoring components or working with style props:

Internal-only references (pipeline.md, injector.md), positioning material (comparison.md, adoption.md), and modes the UI Kit does not ship (tasty-static.md, ssr.md) are intentionally omitted; read them directly in docs/tasty/ if needed.

Glaze Documentation

Bundled in docs/glaze/ (same symlink/copy mechanism). Consult when working on color tokens, theme generation, or contrast tuning — Glaze powers src/tokens/palette.ts.

Stack

  • Styling: @tenphi/tasty (declarative token-aware CSS-in-JS)
  • Accessibility: react-aria + react-stately
  • Icons: @tabler/icons-react + custom icons in src/icons/
  • Testing: Vitest + React Testing Library + Chromatic
  • Build: React Compiler → tsdown (unbundled ESM, es2022). See scripts/compiler/README.md for the shared transform, coverage baseline, and compiled/uncompiled packaged-consumer tests. Review baseline changes explicitly; a successful build with known bailouts does not mean every function is optimized.
  • Storybook: v10 (@storybook/react-vite)
  • React: React and React DOM 19.3+ supported

Creating Components

See src/stories/CreateComponent.docs.mdx (Storybook → Getting Started / Create Component) for the full guide: styleProps vs extractStyles, filterBaseProps, modifiers, sub-elements, React Aria integration, variants, useEvent, and complete examples.

Design System Reference

See src/stories/Usage.docs.mdx (Storybook → Getting Started / Usage) for units, base/spacing/size/shadow/layout tokens, color tokens, typography presets, themes, recipes, modifiers, state syntax, icons, and the form system.

i18n

Full rules in src/i18n/README.md. The short version:

  • Scope: strings a component renders. Anything the component itself puts in front of a user — visible text, aria-label, aria-roledescription, live-region announcements, title — goes through useI18n(): t('component.key', 'English default'). The inline English stays as a belt-and-braces fallback.
  • Not for stories, docs, or tests. Storybook stories, .docs.mdx, and specs are demo and fixture copy, not product UI. Use plain literals there — a locale key that exists only to feed a story is noise in twelve files, and a test that reads its expectation from the bundle asserts nothing about the string.
  • Component props that expose a label stay overrides that win over the translated default: emptyLabel = t('...', 'No items').
  • All 12 locales, every time. en-US is the source of truth; locale-parity.test.ts fails CI if any locale's key set or {{interpolation}} tokens diverge. Interpolation is {{double}} braces with no ICU, so plurals need separate keys rather than a plural rule.
  • If a string doubles as a DOM selector, build the selector from the same t(...) value so the two cannot drift when the language changes.

TypeScript & Exports

  • Module augmentation: src/tasty-augment.d.ts extends @tenphi/tasty with project-specific color tokens, preset names, and theme names.
  • Props naming: Cube{ComponentName}Props. Extend BaseProps/AllBaseProps from @tenphi/tasty; mix in style-prop interfaces (ContainerStyleProps, OuterStyleProps, ColorStyleProps, …) as needed. Form types live in src/shared/.
  • Barrel exports: every category has an index.ts; everything re-exports through src/index.ts.
  • Modern Form adoption: follow docs/modern-form-migration.md. Keep legacy wrappers on their existing backend until explicitly migrated, use selectors for rendering, and preserve legacy inline callback/rule inference in built-consumer fixtures. Path types resolve the supplied path only; do not introduce a recursive enumeration of all model paths.
  • Compound components: Object.assign(Button, { Group: ButtonGroup, Split: ButtonSplit }).
  • Tasty re-exports: only types are re-exported. Runtime imports (tasty, extractStyles, filterBaseProps) come directly from @tenphi/tasty.
  • React Aria props are checked. Adobe's consolidated packages resolve their types even with preserveSymlinks. Omit locally owned props before mixing Aria interfaces with Tasty or Form interfaces: native form/name differ from UI Kit's controller/path props, and Aria's validation messages differ from field messages. Pass the normalized useFieldProps result to native hooks. Upgrade the Adobe dependency family together so scoped compatibility packages and monolithic packages share overlay and i18n contexts.

Testing

  • Helpers: renderWithRoot (wraps with <Root>), renderWithForm (returns { formInstance, ...renderResult }).
  • QA selectors: qa prop → data-qa attribute → screen.getByTestId('name') (testIdAttribute is configured to data-qa).
  • Tasty snapshots: toMatchTastySnapshot() captures markup + CSS together.
  • Not every question needs a spec. If you only want to see what something renders, run pnpm probe instead of writing a spec to delete afterwards.
  • Screen reader: startScreenReader() / readWithScreenReader() from src/test/screen-reader.ts assert what a user hears. See Hearing What a Screen Reader Says.
  • Patterns: see docs/rules/tests.md.