Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ node_modules/
dist/
examples/catalog/dist/
*.tmp.md

# The Claude Design sync: its inputs, its staged converter and the bundle it
# produces. Local tooling, not part of the package.
.design-sync/
.ds-sync/
ds-bundle/
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

# @robomous/ui-core

The Robomous design system, built on top of [shadcn/ui](https://ui.shadcn.com): twenty-two React
components this package owns outright, over Radix UI and Base UI behaviour, and the one stylesheet
they resolve through. Extracted from [Robomous/VisionSet](https://github.com/Robomous/VisionSet),
where it grew up.
The Robomous design system, built on top of [shadcn/ui](https://ui.shadcn.com): forty React
components this package owns outright, over Radix UI and Base UI behaviour — plus cmdk under
`Command` and vaul under `Drawer` — and the one stylesheet they resolve through. Extracted from
[Robomous/VisionSet](https://github.com/Robomous/VisionSet), where it grew up.

## Install

Expand Down
26 changes: 20 additions & 6 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,27 @@ pnpm dlx shadcn@latest add <name>
`components.json` tells the CLI to write into `src/components/` and `src/hooks/` with `@/`
imports, which the build resolves. Read the diff, then adapt: the checklist is in
[DESIGN.md](DESIGN.md), *Adding a component*. In short: colour only through the roles, geometry in
the component, `type="button"`, no exit animation on a menu, export by name from `src/index.ts`,
a behaviour test in `tests/components/`, a specimen in the catalog, a row in
`docs/components/README.md`. Never reinstall an existing component.
the component, `type="button"`, no exit animation on a surface whose trigger can be pressed again on
the next frame, export by name from `src/index.ts`, a behaviour test in `tests/components/`, a
specimen in the catalog, a row in `docs/components/README.md`. Never reinstall an existing
component.

State styles may use either the attribute the behaviour library emits (`data-[state=open]:`,
`data-open:`) or shadcn's variants, which the vendored layer defines; see DESIGN.md, *State
attributes*.
The CLI pulls in an item's registry dependencies, which for most of the newer components include
`button`, `input`, `dialog` and `input-group` — all of them already here and edited. It asks about
each one, and the prompt is interactive: under `CI=1` or a non-terminal stdin it hangs and the
batch stops half-written. Answer it from stdin and the run finishes:

```
yes n | pnpm dlx shadcn@latest add <names> --yes
```

Then check that the files it offered to overwrite are unchanged before reading the diff.

State styles are written in shadcn's variants — `data-open:`, `data-closed:`, `data-active:`,
`data-disabled:`, `data-horizontal:`, `data-vertical:` — never in the attribute a behaviour library
happens to emit (`data-[state=open]:`, `data-[orientation=vertical]:`). A `data-[…]` bracket is for
the values the layer declares no variant for, and for attributes that are not states; see
DESIGN.md, *State attributes*.

## Updating the shadcn layer

Expand Down
63 changes: 40 additions & 23 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

## Purpose and ownership

`@robomous/ui-core` is twenty-two React components this repository owns outright, over behaviour
from Radix UI and Base UI, and the one stylesheet they resolve through. The package is **built on
top of shadcn/ui**: a component starts as an item of the shadcn registry, installed through
`components.json`, and from that moment it is ordinary source here — edited with a reason, a test
and a review like any other file, never regenerated and never compared back to upstream. It is not
an extension of shadcn. shadcn supplies the starting point and the utility and variant layer the
stylesheet vendors; Radix and Base UI supply focus management, keyboard interaction, dismissal,
`aria-*` relationships and the `data-*` state attributes; Robomous owns the API, the styling, the
semantic variants, the geometry and the public contract.
`@robomous/ui-core` is forty React components this repository owns outright, over behaviour from
Radix UI and Base UI — plus cmdk under `Command` and vaul under `Drawer` — and the one stylesheet
they resolve through. The package is **built on top of shadcn/ui**: a component starts as an item
of the shadcn registry, installed through `components.json`, and from that moment it is ordinary
source here — edited with a reason, a test and a review like any other file, never regenerated and
never compared back to upstream. It is not an extension of shadcn. shadcn supplies the starting
point and the utility and variant layer the stylesheet vendors; Radix, Base UI, cmdk and vaul
supply focus management, keyboard interaction, dismissal, `aria-*` relationships and the `data-*`
state attributes; Robomous owns the API, the styling, the semantic variants, the geometry and the
public contract.

The package is product-agnostic. Shells, navigation, domain cards, model selectors, user menus,
billing screens and the like stay in the product that needs them until the *same composition* is
Expand Down Expand Up @@ -176,37 +177,53 @@ from Tailwind's scale; there is no custom type-scale token.

Radix sets `data-state="open|closed|active|…"` and `data-orientation="horizontal|vertical"`. Base
UI sets bare `data-open`, `data-closed`, `data-highlighted`, `data-empty`. Both set a bare
`data-disabled`. A component written before the shadcn layer returned spells the attribute its
library emits — `data-[state=open]:` for Radix, `data-open:` for Base UI — and those components are
not rewritten.
`data-disabled`. Two libraries, two spellings for one state — and a component here writes neither.

**shadcn's variant layer sits under all of it**, vendored in `src/theme/shadcn.css`. It declares
`data-open`, `data-closed`, `data-checked`, `data-unchecked`, `data-selected`, `data-disabled`,
`data-active`, `data-horizontal` and `data-vertical` so that each matches *both* spellings —
`[data-state="open"]` and a bare `[data-open]` — and excludes an explicit `"false"`. That last
clause is why the layer is load-bearing rather than convenient: `SidebarMenuButton` renders
`data-active="false"` for an inactive item, Tailwind's built-in `data-active:` variant matches on
presence alone, and without the layer every item would be styled active. A component installed
from the registry uses shadcn's spelling as written; an older component's explicit spelling keeps
working under the layer unchanged. Both are correct here.
presence alone, and without the layer every item would be styled active.

**Those variants are the spelling.** `data-open:`, never `data-[state=open]:`; `data-horizontal:`,
never `data-[orientation=horizontal]:`; `group-data-disabled/field:`, never
`group-data-[disabled=true]/field:`. One name covers the Radix surface and the Base UI one, so
Dialog and Combobox read alike and a component that changes libraries changes no class. A bracket
survives in exactly two places: a state the layer declares no variant for — Tooltip's
`data-[state=delayed-open]:`, Table's `data-[state=selected]:`, Sidebar's `data-[state=collapsed]:`
— and an attribute that is not a state at all, `data-[slot=…]`, `data-[variant=…]`, `data-[size=…]`,
`data-[side=…]`, `data-[collapsible=…]`.

The breadth is bought with specificity. `:where()` carries none, so `data-open:bg-accent` weighs a
single class where `data-[state=open]:bg-accent` weighed a class and an attribute. It still wins
over an unqualified utility, which Tailwind emits earlier in the layer, and it still loses to a
`group-`/`peer-` qualified rule. What changes is that `hover:`, `focus-visible:` and `disabled:`
now win on a property they share with it — which is the order a control wants: the pointer and the
focus ring are about the here and now, and the state is the background they play against.

`shadcn.css` is never edited. Anything of ours — `cn-rtl-flip`, which the registry's components
name and shadcn defines nowhere — is declared in `styles.css` after the import.
`tests/theme/shadcn.test.ts` holds the copy identical to the installed package.

## Motion

An **enter** animation is free to play: the surface it introduces did not exist a frame ago. An
**exit** animation is not, and the difference is not taste. While an exit animation runs, Radix
keeps the closed surface mounted and its dismissable layer with it, so a press meant to open the
next menu is read as an interaction outside the closing one and swallowed.

**A menu leaves on the frame it is dismissed — `DropdownMenuContent` and `DropdownMenuSubContent`
alike.** Neither carries an exit animation. `tests/components/dropdown-menu.test.tsx` gives any
exit-animation utility a real `animation-name` and a live `getComputedStyle`, so a surface that
lingers fails the way it does in a browser: the next press does not land. A surface whose trigger
cannot be pressed again straight away — a dialog's, a sheet's, a tooltip's — keeps its exit
animation. `TooltipProvider` defaults `delayDuration` to `0`.
**A surface whose trigger can be pressed again on the next frame leaves on the frame it is
dismissed.** That is `DropdownMenuContent` and `DropdownMenuSubContent`, `ContextMenuContent` and
`ContextMenuSubContent`, and `PopoverContent`: none of them carries an exit animation. The test for
each one gives any exit-animation utility a real `animation-name` and a live `getComputedStyle`, so
a surface that lingers fails the way it does in a browser — the next press does not land — and each
file first proves the fixture bites on that component before reading its absence as a pass.

A surface whose trigger *cannot* be pressed again straight away keeps its exit animation: a
dialog's, a sheet's, a drawer's, a tooltip's, a hover card's. The reader has to move a pointer or
find the trigger again, and the frames the animation costs are frames nobody was waiting on.
`TooltipProvider` defaults `delayDuration` to `0`.

`prefers-reduced-motion` sits above all of this: the base layer collapses every animation and
transition to a single frame under that query, so no component opts in.
Expand Down Expand Up @@ -292,7 +309,7 @@ justification written into this file.
| No literal colour in a class | `eslint.config.js`, `pnpm lint` |
| Roles agree between the stylesheet and its mirror; the palette is closed; no `:focus-visible` rule | `tests/theme/tokens.test.ts` |
| The vendored shadcn layer is identical to the installed package, committed, imported, and a dev dependency only | `tests/theme/shadcn.test.ts` |
| Button type, Dialog/Sheet focus and dismissal, Field's explicit contract, menu dismissal, Tabs, Select, Progress, Combobox, Toaster theme, Sidebar toggling and its mobile Sheet | `tests/components/*.test.tsx` |
| Button type, Dialog/Sheet/Drawer focus and dismissal, Field's explicit contract, menu and popover dismissal, Tabs, Select, Progress, Combobox, Command, RadioGroup and ToggleGroup selection, Breadcrumb and Pagination landmarks, Toaster theme, Sidebar toggling and its mobile Sheet | `tests/components/*.test.tsx` |
| The packed tarball installs, its stylesheet compiles under a real Tailwind with the components' utilities, shadcn's layer and no physical palette, its entry imports and renders (Sidebar included, through the rewritten alias), `dist/` carries no `@/` import, `shadcn` is not a runtime dependency, a Button-only bundle stays small | `tests/package/consumer.test.ts` |
| The harness itself | `tests/harness.test.tsx` |

Expand Down
Loading
Loading