Entry point for AI agents working on @cube-dev/ui-kit.
- Package:
@cube-dev/ui-kit - Repository: cube-js/cube-ui-kit
- Storybook: cube-ui-kit.vercel.app
- Styling engine: Tasty (
@tenphi/tasty) - Palette engine: Glaze (
@tenphi/glaze)
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 insrc/stories/CreateComponent.docs.mdx(Getting Started / Create Component). Update these whenever you add components, change the API surface, or modify tokens/presets.
Run this at the start of every task, before reading code, running tests, or trusting any type error:
pnpm install && pnpm rebuild esbuildA 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/glazeProject-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
styleand 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.tsxand.docs.mdxauthoring, including theplay-function rule below - documentation.md —
.docs.mdxstructure + 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 --srand 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-reviewskill reads this file
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.
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
.mdfile. - Use
patchfor bug fixes and small changes;minorfor new features and noticeable breaking changes. - Keep the summary concise and user-focused (
"@cube-dev/ui-kit": patch|minorfrontmatter). - 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).
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.
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.
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.
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: 0on 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
stylesand use that name in the maps:The rule keeps the child's own specificity and a consumer's'@in-dialog': '@parent(id=Dialog, >)', // `, >` = direct parent; drop it for any ancestor display: { '': 'block', '@in-dialog': 'flex' },
styleskey replaces it. Because the state travels with the styles, a consumer can also target the same case:gap: { '': '3x', '@in-dialog': '1x' }. Worked example:FormElementinForm.tsx. It replaced twoDialogrules that silently beat a form's owndisplayandgapstyles. - 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 inRoot.tsx(setGlobalPredefinedStates) rather than exporting a string. - A parent passes values only through channels built for overriding. Those channels are slot styles (a
SlotProvidermerges them under the consumer's ownstyles), custom properties the child reads ($dialog-padding-h), props and mods. - Know what tasty's
gapcompiles to. Ondisplay: flexorgridit becomes the CSSgapproperty. Ondisplay: blockit becomes a bottom margin on every child but the last. Those margins land on whatever goes inside the container, and agap: 0from outside never reaches them. So when a block container's spacing is wrong in some context, change the container's owngapfor that context; don't reset its children.Formstays a block outside dialogs because a flex column would stretch a bareButtonchild to full width and stack inline content. - Check before adding a rule. Ask whether a consumer can still override it with ordinary
styleson 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.
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.
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.
"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.
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.
pnpm storybook— start Storybook on port 6060pnpm build— build library (tsdown, unbundled ESM)pnpm test— run all tests (Vitest); add-- ComponentNameto filter,-uto update snapshotspnpm test:once— the fullpnpm 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_ENVin the script; a test that reads another environment variable adds it there). It never selects tests by changed files. Seescripts/test-once.mjspnpm test:browser— run the*.browser.test.tsxspecs 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:browserfor computed values, geometry and screenshots;render --srfor what a screen reader says. See Inspecting Rendered HTML & CSS and Hearing What a Screen Reader Sayspnpm fix— lint + format (Oxlint + Prettier)pnpm size— check bundle size limitspnpm chromatic— visual regressionpnpm chromatic:report— snapshot inventory + TurboSnap blast radius.pnpm chromatic:checkis the CI gate;pnpm chromatic:duplicatesfinds stories that photograph the same thing. All three readstorybook-static, so runpnpm build-storybookfirst. See The Snapshot Budgetpnpm add-icons— add new icons from tablerpnpm 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);--updateratchets it after a reviewed change. See React Compilerpnpm diagnostics:hooks --check— whole runtime-source official Hooks ratchet plus strict Effect Event adoption checks.--updatetightens the canonical baseline after reviewed improvements. See React Compiler rules.pnpm diagnostics:form— Form/input view of the same Hooks baseline;--checkrejects growth. Update throughdiagnostics:hooks --update. Seesrc/components/form/Form/legacy-contract/README.mdpnpm check:react-package— verify supported React peers and native compiler imports before publishing or testing a packed build.pnpm check:consumer-declarations— afterpnpm pack:test, install the packed build in a consumer and emit declarations fortasty(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 bundlesdist/node_modules. Name a compound component with an exported interface, asLayoutComponentdoes.pnpm diagnostics:complexity— every function insrc/ranked by cognitive complexity (SonarSource's metric, fromeslint-plugin-sonarjsrunning in oxlint), worst first. Options:--limit N,--all,pnpm -s … --json..oxlintrc.jsoncaps every function with the same rule (sonarjs/cognitive-complexity), sopnpm lintfails 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. Seescripts/complexity-report.mjspnpm audit-defaults— regenerate the lint plugin's defaults registry (src/eslint-plugin/defaults.generated.ts). Run whenever you change a default prop value.pnpm testfails 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/tastyor@tenphi/glazeto the latest version. Pass--version=X.Y.Zto pin a specific version.
- Tasty's experimental type-aware JSX analysis is enabled only in this repo's
.oxlintrc.jsonthroughsettings.tasty.typeAwareJSX, usingtsconfig.jsonand 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:typesand thetschalf ofpnpm test:types:consumerrun TypeScript 7 (thetypescript-nativealias, ~7× faster).typescriptstays 5.x because TypeScript 7 ships no compiler API, and tsdown's declaration build,audit-docs,check-declaration-bindings.mjs, the export parsing incheck-consumer-declarations.mjsand the type-aware lint above all need it. Both packages ship atscbinary, so a baretscmay run either one; use the scripts. TypeScript 7 removedbaseUrl, so import by relative path, neverfrom 'src/…'. - Node 24, pinned in
.nvmrcand 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 topnpm@10.34.5viapackageManager; no Corepack involved. Note the published package still declaresengines.node >=22.0.0— that is the floor for consumers, not for building this repo. - After
pnpm install, runpnpm rebuild esbuild(postinstall is blocked inpnpm-workspace.yaml). Do this at the start of every task — see Before You Start. - Husky hooks:
pre-commitrunspnpm lint-staged;pre-pushrunsnode scripts/test-once.mjs --pre-push, which skips likepnpm test:onceonly when every pushed ref also points at that tree, and otherwise runs the full suite on the working tree as before. So apnpm test:oncepass covers the next push of that tree. Skip only intentionally (--no-verifyorHUSKY=0). - No external services or databases required for local development.
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:
getting-started.md,methodology.md,design-system.md,react-api.md,dsl.md,styles.md,configuration.md,debug.md
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.
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.
- Styling:
@tenphi/tasty(declarative token-aware CSS-in-JS) - Accessibility:
react-aria+react-stately - Icons:
@tabler/icons-react+ custom icons insrc/icons/ - Testing: Vitest + React Testing Library + Chromatic
- Build: React Compiler → tsdown (unbundled ESM,
es2022). Seescripts/compiler/README.mdfor 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
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.
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.
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 throughuseI18n():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-USis the source of truth;locale-parity.test.tsfails 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.
- Module augmentation:
src/tasty-augment.d.tsextends@tenphi/tastywith project-specific color tokens, preset names, and theme names. - Props naming:
Cube{ComponentName}Props. ExtendBaseProps/AllBasePropsfrom@tenphi/tasty; mix in style-prop interfaces (ContainerStyleProps,OuterStyleProps,ColorStyleProps, …) as needed. Form types live insrc/shared/. - Barrel exports: every category has an
index.ts; everything re-exports throughsrc/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: nativeform/namediffer from UI Kit's controller/path props, and Aria's validation messages differ from field messages. Pass the normalizeduseFieldPropsresult to native hooks. Upgrade the Adobe dependency family together so scoped compatibility packages and monolithic packages share overlay and i18n contexts.
- Helpers:
renderWithRoot(wraps with<Root>),renderWithForm(returns{ formInstance, ...renderResult }). - QA selectors:
qaprop →data-qaattribute →screen.getByTestId('name')(testIdAttributeis configured todata-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 probeinstead of writing a spec to delete afterwards. - Screen reader:
startScreenReader()/readWithScreenReader()fromsrc/test/screen-reader.tsassert what a user hears. See Hearing What a Screen Reader Says. - Patterns: see docs/rules/tests.md.