Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 e7b34ce6ae fix(web): вернуть нативные токены ReUI и добавить шапку страницы пользователей
quality / changes (push) Successful in 6s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 44s
CD / quality (push) Successful in 52s
CD / publish (push) Successful in 1m21s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 14:33:21 +07:00
DenozordecandClaude Sonnet 5.5 9b05fcf03a feat(web): перенести OIDC, ссылки приложений и пользователей на новый дизайн
quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m37s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 14:09:04 +07:00
DenozordecandClaude Sonnet 5.5 69b3553f6d feat(web): перенести страницы Приложения, Аккаунт, Входы и Изменения на новый дизайн
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 14:08:10 +07:00
DenozordecandClaude Sonnet 5.5 148a778ba1 feat(web): оболочка портала на блоках макета — меню, шапка, монитор, палитра ⌘K
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 13:59:35 +07:00
DenozordecandClaude Sonnet 5.5 6898d2255d refactor(web): вынести общие блоки макета в components/portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 13:53:59 +07:00
DenozordecandClaude Sonnet 5.5 b89e72ec1b fix(design-preview): плитки монитора системы не разъезжаются
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 46s
CD / quality (push) Successful in 56s
CD / publish (push) Successful in 1m27s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:14:13 +07:00
DenozordecandClaude Sonnet 5.5 41d9325d14 fix(design-preview): убрать плашку «Обновлено · Live · авто» из шапки
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:13:16 +07:00
DenozordecandClaude Sonnet 5.5 f6466d9d7c revert(design-preview): вернуть компоновку каркаса как в MikrotikManager v2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:11:28 +07:00
DenozordecandClaude Sonnet 5.5 609bea6ad7 feat(design-preview): каркас на блоке app-shell-12 из ReUI PRO
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:03:55 +07:00
DenozordecandClaude Sonnet 5.5 f97604c23e fix(design-preview): вкладки line как в ReUI — без сплошной нижней линии
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:57:16 +07:00
DenozordecandClaude Sonnet 5.5 1395e2a70b fix(design-preview): убрать двойную линию под вкладками окна прав
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:54:32 +07:00
DenozordecandClaude Sonnet 5.5 a07cb0e55a feat(design-preview): иконки в карточках KPI
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 16:54:54 +07:00
DenozordecandClaude Sonnet 5.5 c8d69bbdfa fix(design-preview): палитра shnt Ops, окно прав и палитра ⌘K как на досках
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 16:37:40 +07:00
DenozordecandClaude Sonnet 5.5 700ad508d8 fix(design-preview): правки по ревью — пороги, высоты вкладок, контраст фокуса, счётчик вкладок
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:57:22 +07:00
DenozordecandClaude Sonnet 5.5 b82c8031c2 fix(design-preview): размеры, отступы и пороги ширины как на досках Auth Portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:53:25 +07:00
DenozordecandClaude Sonnet 5.5 3e1ad15ba3 fix(design-preview): палитра MikrotikManager v2 для макета Auth Portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:43:30 +07:00
DenozordecandClaude Sonnet 5.5 1b89135186 fix(design-preview): окна создания, подписи и кнопки как на досках Auth Portal
Окна «Создать пользователя» и «Создать OIDC-клиент» по доске с проверкой полей; баннер безопасности
всегда виден; кнопки действий без иконок; подписи иконок приложений как на доске; поиск в таблице passkeys.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:44:19 +07:00
DenozordecandClaude Sonnet 5.5 cd6516edd6 fix(design-preview): полоса KPI, баннеры и шапка точно как на досках Auth Portal
Полоса показателей — один Frame с линиями и плашками, колонки по порогам контейнера; баннер в одну строку
с «Объяснить»; бренд «Единый вход · SSO», заголовки групп капсом; пороги монитора в шапке как на доске.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:41:00 +07:00
DenozordecandClaude Sonnet 5.5 afbc6fc366 feat(design-preview): перенос досок Auth Portal 1 к 1 — таблицы, вкладки, формы, права
Таблицы по анатомии доски: «Таблица/Карточки», поиск, «Колонки», виды линией со счётчиками, панель
массовых действий, переключатель и меню «⋮» в строке. Вкладки страниц — линия с мелким счётчиком.
Строки настроек (пароль, ротация журнала, меню), «Объяснить» в баннерах с панелью разбора,
палитра с ассистентом и OIDC-клиентами, сворачиваемые группы меню, плашка «Обновлено · Live»,
данные досок «Изменения» и «Доступ по приложениям», права доступа вкладками.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:35:45 +07:00
DenozordecandClaude Sonnet 5.5 709dd93548 fix(design-preview): «Приложения» и «Изменения» точно как на досках канваса
Плитки приложений и лента событий собраны по разметке доски: Frame с шапкой, сетка auto-fit
от 260px, иконка, плашка, описание и адрес; лента — время, точка с линией, плашка и подпись.
На «Приложениях» возвращена полоса KPI, «Весь журнал» открывает таблицу.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:18:06 +07:00
DenozordecandClaude Sonnet 5.5 60e0b886fc fix(design-preview): плитки приложений внутри рамки и стиль MikrotikManager v2
QuickActionGrid получил autoFit: Frame с отступами и сетка auto-fit от 17rem, плитки больше не
вылетают за границы. Центр состояния в шапке, меню пользователя (тема, плотность, контраст),
точки проблем в меню, размытая шапка, высокий контраст и видимый фокус приведены к макету v2.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:15:12 +07:00
DenozordecandClaude Sonnet 5.5 8bbaab62e4 fix(design-preview): убрать страницу пользователя — дублировала панель прав
Права, сброс passkeys, отключение и удаление уже есть в панели прав и меню строки,
сессии и события — в «Входах» и «Изменениях». Страница откроется из коммита 007ece4.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 03:11:43 +07:00
DenozordecandClaude Sonnet 5.5 c8351424f4 feat(design-preview): матрица доступа «пользователи × приложения» (solution-users-3)
Переключатели в ячейках, счётчик выданных доступов в заголовке колонки, заблокированные ячейки
администраторов, пометки «Не сохранено». Исправлена вёрстка рейки вкладок профиля.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:39:06 +07:00
DenozordecandClaude Sonnet 5.5 007ece4575 feat(design-preview): страница пользователя с рейкой вкладок (solution-users-2)
Права, вход и passkeys, сессии, активность и опасная зона. Каталог пользователей вынесен в общее
состояние макета, поэтому правки видны и в списке, и в профиле. Переходы: меню строки «Профиль»,
«Открыть профиль» в панели прав, хлебные крошки в шапке.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:35:57 +07:00
DenozordecandClaude Sonnet 5.5 f0a257220f feat(design-preview): права доступа — широкая боковая панель с двумя колонками
Слева приложения с переключателем и счётчиком выданных разделов, справа разделы с уровнем
«Нет / Чтение / Запись / Админ». «Сохранить права» активна только при изменении,
закрытие с правками спрашивает подтверждение.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:33:06 +07:00
DenozordecandClaude Sonnet 5.5 e02cfad0b1 feat(design-preview): пометки строк «Не сохранено» и «Будет удалено», массовое удаление с подтверждением
Строки с накопленными правками подсвечиваются и получают плашку (getRowStatus из DataGrid v9),
удаление применяется при «Сохранить», в каталоге пользователей добавлено «Удалить…» в панели
массовых действий.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:26:37 +07:00
DenozordecandClaude Sonnet 5.5 41f5669812 feat(design-preview): перетаскивание строк в «Ссылках приложений» через dnd-kit
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m32s
quality / api (push) Successful in 42s
CD / quality (push) Successful in 2m25s
CD / publish (push) Successful in 2m52s
PreviewSectionGrid получил onReorder: ручка DataGridTableDndRowHandle и DataGridTableDndRows
из ReUI. Порядок меняется перетаскиванием, меню строки осталось запасным путём.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:03:05 +07:00
DenozordecandClaude Sonnet 5.5 4f5026980a chore(docs): обновить skill ReUI до 68f0e6fa91 и описать миграцию
Установщик ReUI обновил skill для Claude/Codex/Cursor/OpenCode, правила в AGENTS.md
и MCP-конфиги (ключ только ссылкой на REUI_LICENSE_KEY). План и решения — docs/reui-upgrade.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:58:41 +07:00
DenozordecandClaude Sonnet 5.5 7c4430d240 chore(web): обновить компоненты ReUI и перейти на TanStack Table v9
Обновлены из реестра data-grid, filters, badge, frame, alert, icon-tile, icon-stack,
timeline и зависимые примитивы @authportal/ui. @tanstack/react-table 8.21 → 9.3,
добавлены dnd-kit, react-virtual, date-fns. Фильтры AdminUsersGrid переведены на
дерево FilterQuery, таблицы — на useTable + dataGridFeatures. Русские подписи вынесены
в lib/reui-ru.ts. Импорты cn из реестра заменены на @authportal/ui/lib/utils.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:58:31 +07:00
DenozordecandClaude Sonnet 5.5 a55d79c974 feat(design-preview): страница «Ссылки приложений» по канвасу на блоках ReUI PRO
Порядок с номером и ручкой, плитки иконок, переключатель «В меню» в строке, меню строки
(в начало, выше, ниже, в конец), сброс порядка, вкладка «Меню» в стиле settings-10 с
предпросмотром, форма приложения с выбором иконки и режима входа.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:47:10 +07:00
DenozordecandClaude Sonnet 5.5 1a46aaa6a5 feat(design-preview): журнал изменений — лента, таблица и ротация без дублирующих фильтров
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:39:44 +07:00
DenozordecandClaude Sonnet 5.5 5de88e18c8 feat(design-preview): упростить страницы — без технической строки источника, дублей и лишних KPI
Убрана строка «Получено… GET /api/…» из шапок страниц (свежесть в кнопке i), дублирующий
баннер на «Пользователях», KPI на «Приложениях». Контент ограничен шириной 1480px.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:35:17 +07:00
DenozordecandClaude Sonnet 5.5 f486b72884 feat(design-preview): шапка как в канвасе — заголовок, инфо-кнопка и монитор «Внимание»
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:08:42 +07:00
DenozordecandClaude Sonnet 5.5 a31a964228 feat(design-preview): плитки приложений с адресом и способом входа как в канвасе
В QuickActionGrid добавлены необязательные meta и badgeVariant (обратно совместимо).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 00:19:43 +07:00
DenozordecandClaude Sonnet 5.5 fe5d58b13b feat(design-preview): каталог пользователей и матрица доступа как в канвасе
Колонки: участник, статус, роль, приложения, passkey, активность · IP; виды по статусу,
массовое включение/отключение, меню строки, главная кнопка «Создать пользователя» в шапке
страницы, вкладка «Доступ по приложениям».

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 00:17:43 +07:00
DenozordecandClaude Sonnet 5.5 67a0510eeb feat(design-preview): привести макет к дизайн-системе shnt Ops
Шрифты Geist и Geist Mono (только пока смонтирован макет), плашки статуса с глифом
и точкой в первой колонке, счётчики в видах таблиц с запоминанием вида,
закреплённые пункты меню, автосворачивание меню до 1100px, индикатор свежести
в шапке, вызов палитры кнопкой только на узких экранах, главная кнопка в Sheet
и предупреждение «Закрыть без сохранения».

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 22:29:47 +07:00
DenozordecandClaude Sonnet 5.5 bd69b27e5c feat(design-preview): KPI-плитки с иконками, тонами и пояснениями
Плитки собираются хелпером kpi(): иконка по тону, значение с пояснением, бейдж статуса.
Для четырёх плиток сетка переходит на 2 колонки раньше (kpi-cols).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 22:14:30 +07:00
DenozordecandClaude Sonnet 5.5 7f9d69d27d fix(design-preview): убрать блоки «Состояния раздела»
Блоки были переносом из канваса и не несли пользы на страницах макета.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 22:12:02 +07:00
DenozordecandClaude Sonnet 5.5 59d68a3a45 fix(design-preview): отступ под горизонтальным скроллом таблиц
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 22:10:42 +07:00
DenozordecandClaude Sonnet 5.5 0be272f917 fix(design-preview): переключатели вида и периода на компоненте Tabs
Кнопки с aria-pressed выглядели криво в тёмной теме; заменены на общий Segmented.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 22:10:01 +07:00
DenozordecandClaude Sonnet 5.5 157396613d feat(design-preview): живой макет портала на ReUI PRO
Добавлена ветка маршрутов /design-preview без авторизации и без запросов к API:
вход (вне оболочки), приложения, безопасность (passkeys), пользователи
(права доступа по приложениям и разделам, создание, массовые действия,
удаление вводом email, сброс passkeys), входы (история и сессии),
изменения (лента Timeline, таблица, ротация журнала), OIDC-клиенты
(issuer, создание, секрет один раз), ссылки приложений.

Оболочка: сайдбар с группами «Портал» и «Админ», переключатель на 8
приложений, свёрнутый вид с точками проблем, шапка с крошками и
монитором, меню пользователя с темой/плотностью/контрастом, палитра ⌘K,
панель «Не сохранено» с diff. Данные — суррогатные, названия из
@authportal/shared. В audit-log-timeline добавлены min-w-0, чтобы лента
не вызывала горизонтальный скролл на узких экранах.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 22:05:52 +07:00
Denozordec 5acff590bc docs(deploy): чинить SQLITE_READONLY после перехода на не-root образ
quality / commitlint (push) Skipped
quality / changes (push) Successful in 6s
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Successful in 8s
CD / publish (push) Successful in 24s
./data и app.db, созданные старым root-контейнером, нужно один раз
отдать uid 1000 (node) — иначе migrateSchema падает с SQLITE_READONLY.
2026-09-23 18:45:48 +07:00
Denozordec 2e947b5a31 fix(ci): мульти-коммит пуш ломал детект путей
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m5s
quality / api (push) Successful in 53s
CD / quality (push) Successful in 2m8s
CD / publish (push) Successful in 2m33s
checkout для push-событий был fetch-depth: 2, но github.event.before
при пуше пачки коммитов лежит глубже HEAD~1 — git diff падал с
'bad object'. Теперь fetch-depth: 0 + fallback на полный пайплайн,
если before-SHA недоступен (переписанная история).
2026-09-23 16:57:38 +07:00
Denozordec 8d8bddb454 ci(cd): turbo-кэш, гейты typecheck/lint, таймауты, non-root образ
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Failing after 11s
CD / publish (push) Skipped
- .turbo в actions/cache (пересборки shared/db переиспользуются между прогонами)
- typecheck в web/api джобах, lint бэкенда в api-джобе
- убран пустой turbo test --filter=web (в web нет тестов)
- timeout-minutes на все джобы
- Dockerfile: USER node + --chown; прекомпрессия статики gzip+brotli на этапе
  сборки (Traefik не сжимает); compose: chown 1000:1000 data в инструкции
2026-09-23 14:51:38 +07:00
Denozordec 3cef8c33c2 build(monorepo): catalog, typecheck/lint задачи, унификация версий
- pnpm catalog: один typescript 6.0.3 и vitest 4.1.10 на весь монорепо
- задача turbo typecheck; tsc вынесен из build web (блокировал vite build)
- eslint-конфиги для api/db/shared (бэкенд не линтился)
- turbo test больше не зависит от ^build (тесты идут по исходникам)
- react-day-picker перенесён из web в ui (потребитель calendar.tsx)
- из web удалены неиспользуемые motion/@dnd-kit×4/react-virtual/date-fns
2026-09-23 14:51:38 +07:00
Denozordec e135150c2d test(api): общий хелпер с фиксированным RSA-ключом
Фиксированный ключ (OIDC_RSA_PRIVATE_KEY) вместо generateKeyPair
на каждое построение приложения: фаза тестов 9.9с → 1.6с (×6).
Локальные копии buildTestApp/adminToken в 4 файлах заменены на helpers.ts
2026-09-23 14:51:38 +07:00
Denozordec c48132e89e fix(api): ошибки типов, скрытые отсутствием typecheck
- jose v6 не экспортирует KeyLike → CryptoKey
- SimpleWebAuthn v13 требует Uint8Array<ArrayBuffer>
- targetType 'credential' в AUDIT_TARGET_TYPES (писался в аудит passkey-ов)
- target-app: начальные значения host/path/search никогда не читались
2026-09-23 14:51:38 +07:00
Denozordec e107af8e43 refactor(web): удалить мёртвый код (~5.9К строк)
- демо-блоки blocks/ (sheet-9, solution-users-1/6, settings-5, auth-18) —
  не импортировались; auth-logo перенесён в components/auth-logo.tsx
- варианты грида data-grid-table-dnd/-dnd-rows/-virtual и ui/svgs — не импортировались
- убраны исключения blocks из tsconfig и eslint
- AGENTS.md: добавить apps/api/db/shared в описание стека
2026-09-23 14:51:25 +07:00
Denozordec 15073f53d9 perf(web): разбиение бандла и runtime-оптимизации
- autoCodeSplitting роутов: начальный JS 1012КБ/298гз → 426КБ/131гз (−58%),
  админ-чанки грузятся по требованию
- фильтры грида: убрана искусственная задержка 200мс
- SystemMonitorPopover: поллинг 4 запросов/30с только при открытом поповере
- /me в beforeLoad через ensureQueryData (кэш вместо запроса на каждую навигацию)
- глобальный staleTime 30с (мутации инвалидируют кэш явно)
- режим analyze + rollup-plugin-visualizer (pnpm --filter web analyze)
2026-09-23 14:51:14 +07:00
Denozordec 87f35631b4 perf(api): горячие пути OIDC и батч-запросы
- кэш importJWK: не импортировать RSA-ключ на каждый /oauth/userinfo
- Cache-Control для /.well-known/openid-configuration и jwks.json
- индекс refresh_sessions(token_hash) — было full scan на каждом authorize/logout
- GET /admin/users: 3 SELECT на пользователя → батч-запросы (N+1)
- revokeAllSessionsForUser: цикл UPDATE → один UPDATE
- preCompressed статики (файлы готовит Dockerfile)
2026-09-23 14:51:14 +07:00
DenozordecandCursor fd7d9fd13c feat(apps): добавить MikrotikManager app id mm
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / web (push) Successful in 40s
quality / api (push) Successful in 36s
CD / quality (push) Successful in 1m37s
CD / publish (push) Successful in 1m40s
Регистрация mm в каталоге приложений, permissions mm:*, target-app и docs.

Co-authored-by: Cursor <[email protected]>
2026-09-04 20:38:27 +07:00
DenozordecandCursor 01d3a79e13 test(app-switcher): add tests for CDN app integration
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 34s
CD / quality (push) Successful in 43s
CD / publish (push) Successful in 1m25s
- Updated app-switcher tests to include verification for the newly integrated CDN app.
- Added assertions to check the presence and authentication mode of the CDN app in the response.

Co-authored-by: Cursor <[email protected]>
2026-09-04 14:18:00 +07:00
DenozordecandCursor 0f1f89776b feat(cdn): integrate CDN Manager into the application
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 57s
quality / api (push) Failing after 30s
CD / quality (push) Failing after 1m36s
CD / publish (push) Skipped
- Updated environment configurations to include CDN Manager in the RETURN_TO_ALLOWLIST.
- Enhanced target app resolution to recognize CDN-related hosts.
- Added CDN Manager to the application switcher and updated relevant documentation.
- Included tests to verify the correct mapping of CDN hosts.

Co-authored-by: Cursor <[email protected]>
2026-09-04 14:11:34 +07:00
DenozordecandCursor a591d12c69 chore(reui): обновить ReUI agent skill до актуальной версии
quality / changes (push) Successful in 6s
quality / web (push) Skipped
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Successful in 8s
CD / publish (push) Successful in 33s
Co-authored-by: Cursor <[email protected]>
2026-08-19 20:03:54 +07:00
DenozordecandCursor 46dc2f714c feat(auth): добавить вход по passkey
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s
Альтернатива паролю на портале; SSO приложений без изменений.

Co-authored-by: Cursor <[email protected]>
2026-08-19 10:24:46 +07:00
DenozordecandCursor f76b89c1df ci: перевести пайплайн на quality bake и alpine
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m7s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 2m3s
CD / publish (push) Successful in 3m31s
Co-authored-by: Cursor <[email protected]>
2026-08-19 01:30:57 +07:00
DenozordecandCursor a59e173f6d ci: переключить registry на git.shx.one
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 46s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Обновить Gitea Actions, compose и ссылки в документации.

Co-authored-by: Cursor <[email protected]>
2026-08-17 16:43:39 +07:00
DenozordecandCursor 778c42bad8 fix(ui): добавить монитор системы и выровнять chrome
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m18s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Подключить SystemMonitor, IconTile, QuickActionGrid на /apps и skip-link.

Co-authored-by: Cursor <[email protected]>
2026-08-17 15:36:55 +07:00
DenozordecandCursor 4ce05a6669 feat(oidc): enhance SSO target app resolution and audit logging
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m5s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Updated targetAppFromReturnTo function to handle OIDC authorization unwrap and added search parameter processing.
- Integrated target app resolution into the OIDC route for improved audit logging of SSO handoffs.
- Added a test case to verify the logging of the target app during the authorization process.
- Updated documentation to reflect changes in audit logging for the Technitium DNS application.

Co-authored-by: Cursor <[email protected]>
2026-08-11 20:01:51 +07:00
DenozordecandCursor 36c4091dbf feat(oidc): add CreateOidcClientSheet component and update imports
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m25s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Introduced the CreateOidcClientSheet component for OIDC client creation.
- Updated the index export to include the new component.
- Modified the OIDC admin route to import and utilize the new CreateOidcClientSheet.

Co-authored-by: Cursor <[email protected]>
2026-08-11 19:40:39 +07:00
DenozordecandCursor 0b6fa65b08 feat(reui): update ReUI components and documentation
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m30s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Added new OIDC configuration options in `.env.example`.
- Expanded documentation in `AGENTS.md` to include OIDC endpoints and admin UI.
- Updated ReUI skill version and component count from 17 to 20 across various documentation files.
- Enhanced `README.md` and other related files to reflect the new component structure and usage guidelines.

Co-authored-by: Cursor <[email protected]>
2026-08-11 16:58:47 +07:00
DenozordecandCursor cc38bf06f8 feat(admin): журналы входов и изменений с IP/UA и сессиями
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m57s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip;
SSO handoff и revoke refresh-сессий; улучшены audit-карточки.

Co-authored-by: Cursor <[email protected]>
2026-07-31 03:12:39 +07:00
DenozordecandCursor 8b1203f5e9 fix(db): индексы audit source_app/event_id только после ALTER
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m59s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
На существующей audit_log CREATE INDEX по source_app падал до добавления колонки — контейнер crash-loop / Traefik 404.

Co-authored-by: Cursor <[email protected]>
2026-07-21 13:36:10 +07:00
DenozordecandCursor 57e34ff5e9 feat(admin): ingest аудита из apps и users 1:1 с Sheet журнала
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m46s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Добавлен POST /api/v1/ingest/audit, фильтры source_app/user_id, last_login_at; таблица пользователей по solution-users-1 с журналом в Sheet.

Co-authored-by: Cursor <[email protected]>
2026-07-21 13:24:28 +07:00
DenozordecandCursor 26fc8253c3 feat(admin): журнал аудита с ротацией и апгрейд таблицы пользователей
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m37s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Добавлен audit log (solution-users-6) с retention N дней и hourly purge; таблица пользователей приведена к DNA solution-users-1 (Filters, avatar, sorting).

Co-authored-by: Cursor <[email protected]>
2026-07-21 04:20:47 +07:00
DenozordecandCursor e430abe98b chore(rules): правило kpi-hybrid — только hybrid KPI из kit
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 48s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-21 02:35:52 +07:00
DenozordecandCursor 4a4e3c7c46 chore(security): игнорировать локальные mcp.json
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 46s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
.cursor/mcp.json и .mcp.json в gitignore; убраны из индекса; добавлен mcp.json.example без секрета.

Co-authored-by: Cursor <[email protected]>
2026-07-21 00:30:08 +07:00
Denozordec ece2b1e7b9 feat(mcp): add ReUI server configuration to mcp.json
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 50s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Introduced ReUI server details with the API URL for mcp integration.
- Updated mcpServers structure to include the new server entry.
2026-07-21 00:25:14 +07:00
DenozordecandCursor 779186f1e4 feat(web): добавить ReUI PRO rules, KpiStatGrid и убрать ModeToggle
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m40s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Полный комплект frontend-правил, hybrid KPI/QuickAction kit, contract NavUser; удалён мёртвый ModeToggle.

Co-authored-by: Cursor <[email protected]>
2026-07-21 00:23:04 +07:00
DenozordecandCursor 82fb47b610 fix(web): добавить иконку fw в AppsMenu
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m57s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
AppId fw уже в каталоге, но APP_ICONS не содержал ключ — ломал tsc build.

Co-authored-by: Cursor <[email protected]>
2026-07-20 19:59:18 +07:00
DenozordecandCursor 9b4c7b8b9d feat(auth): добавить приложение fw для EvoFirewall
Build and Push Auth Portal Docker Image / build-and-push (push) Failing after 1m15s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Каталог permissions fw:*, app switcher и docs; убран bgp:firewall после cutover.

Co-authored-by: Cursor <[email protected]>
2026-07-20 19:50:54 +07:00
Denozordec 143db3d72e feat(auth): add user directory endpoint for listing active users
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m7s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Introduced a new endpoint '/api/v1/directory/users' to retrieve a filtered list of active users.
- Implemented query handling to allow searching by email, name, or ID.
- Limited the response to the first 50 matching users for performance optimization.
2026-07-19 12:49:42 +07:00
Denozordec 09ed7f4dc4 feat(auth): refactor authentication routes and enhance JWT handling
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m49s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Removed unused permission and app retrieval logic from the login process.
- Introduced a new endpoint for reissuing access tokens to handle role changes and account switches.
- Updated JWT payload handling to improve clarity and maintainability.
- Enhanced the readJwtPayload function for better decoding of JWT claims.
2026-07-19 02:22:23 +07:00
Denozordec ed1699503a feat(sidebar): integrate NavUser component into SidebarFooter and clean up SiteHeader
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m54s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Added NavUser component to the SidebarFooter for user information display.
- Refactored SiteHeader by removing unused imports and components related to user account management.
- Updated breadcrumbs logic to include a new path for admin apps.
- Adjusted SidebarMenuItem formatting for improved readability.
2026-07-19 02:13:08 +07:00
Denozordec bb17ad6f4d feat(auth): enhance JWT claims and app switcher configuration
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m44s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Added support for optional tenant IDs in JWT claims for user permissions.
- Updated auth routes to include tenant information in the JWT payload.
- Enhanced app switcher configuration to handle tenant IDs without exposing them publicly.
- Improved documentation for EvoBGP tenant ID integration and its usage in JWT.
2026-07-19 01:00:37 +07:00
Denozordec 21d6603b57 feat(auth): integrate normalizePermissionKeys for consistent permission handling
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m54s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Updated auth, admin, and auth-guards routes to utilize normalizePermissionKeys for permissions.
- Enhanced permission mapping to ensure legacy keys are correctly transformed to current catalog keys.
- Improved overall permission validation and user access management across the application.
2026-07-19 00:19:18 +07:00
Denozordec cfbe1fceda docs(README): добавлена ссылка на интеграцию с EvoBGP в документацию; обновлено описание приложения BGP и расширен каталог разрешений
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m56s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
2026-07-18 23:24:00 +07:00
DenozordecandCursor 0fa4b7adea feat(app-switcher): централизовать ссылки приложений в portal settings
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m49s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Публичный GET и admin PUT/UI /admin/apps; каталог и chrome читают URL из store.

Co-authored-by: Cursor <[email protected]>
2026-07-18 20:59:01 +07:00
Denozordec 83bfc0355d feat(auth): enhance CreateUserSheet with improved layout and error handling
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m39s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
- Added useEffect to reset admin state when the sheet is closed.
- Updated SheetContent to include a close button and improved styling.
- Enhanced form structure with better accessibility and user experience.
- Integrated error display using Alert component for form submission feedback.
2026-07-18 20:24:51 +07:00
Denozordec b5a50ac3b3 docs: update deployment instructions for Auth Portal with Traefik in a single Docker Compose stack
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 46s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
2026-07-18 19:33:22 +07:00
Denozordec 638df5d679 docs(README): добавлены инструкции по локальному запуску и развертыванию с Traefik
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 46s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
2026-07-18 18:56:37 +07:00
Denozordec a4ece3d344 refactor(auth): streamline logout route and update route tree for logout integration
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 3m15s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
2026-07-18 18:36:21 +07:00
Denozordec 2a6adf7af8 feat(docs): добавить ссылку на интеграцию с Cloudflare Domain Manager в README
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 47s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
2026-07-18 18:26:28 +07:00
DenozordecandCursor c23eb5ead0 fix(auth): не отдавать SSO с просроченным JWT и не бить /me
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m38s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
getToken чистит expired; при плохом return_to остаёмся на login без /me.

Co-authored-by: Cursor <[email protected]>
2026-07-18 16:08:50 +07:00
DenozordecandCursor e2c45cf0fa feat(vps): добавить permission vps:spaces:admin и docs Spaces
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m42s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Каталог RBAC и секция Spaces в integrate-vps-tracker для multi-user пространств.

Co-authored-by: Cursor <[email protected]>
2026-07-18 15:42:54 +07:00
DenozordecandCursor 4ff440fb34 fix(auth): SSO return_to без /me чтобы не бить rate limit
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m49s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-18 14:09:21 +07:00
DenozordecandCursor 961242506b fix(auth): runtime allowlist return_to с private для LAN SSO
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 3m15s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-18 14:00:05 +07:00
DenozordecandCursor 1cc58977fd feat(auth): allowlist private для LAN return_to
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 3m37s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-18 13:49:37 +07:00
Denozordec bac95bdb2e First Commit
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 5m26s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
2026-07-18 13:27:37 +07:00
169 changed files with 7207 additions and 6268 deletions

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
---
name: reui
description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind. Use for any UI work in a project with @reui in components.json or a components/reui folder, and in any shadcn project that asks for data grids, kanban boards, dashboards or full pages: building or restyling pages, forms and tables; colors, theme tokens and dark mode; spacing, typography and layout; animation and hover motion; installing ReUI components, examples, Pro blocks or Motion Icons. Works with the ReUI MCP."
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
---
> **ReUI skill version `68f0e6fa91`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer in the mode it was installed with to update this skill: `get_agent_skill` -> `install.recommended` from the project root, with `REUI_GLOBAL=1` before `node` when this file is `~/.claude/skills/reui/SKILL.md` or `~/.agents/skills/reui/SKILL.md` (a global install). Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI
ReUI is an existing design system on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here.
It ships free components, free `c-*` examples, Pro blocks and Ultimate Motion Icons through the shadcn CLI; the ReUI MCP finds them and serves their APIs.
## Order of authority
1. The installed style (`components.json` `style`, baked into `components/ui` and `components/reui`): it owns radius, height, padding, shadow and overlay motion.
2. The installed component's variants and props (`size`, `variant`, `spacing`).
3. These rules and the topic files.
4. Other design skills, then your taste: where they disagree with 1 to 3, ReUI wins. Never redefine `--ease-out` or any Tailwind built-in.
## Before you write UI code, write this plan in your reply
1. Surface: operational, marketing or flow.
2. Archetype: 1 from [compose.md](./rules/compose.md).
3. Container family: Frame-led, Card-led, or Cards for the KPIs and a Frame for the table.
4. Density: dense (`size="sm"` on controls that take it) or default.
5. Regions: top to bottom, each with the item that fills it.
6. States: empty, loading and error for each data region.
7. Motion: none, or the 1 entrance.
Then build exactly that plan; if you deviate, say where.
## The loop
1. **Find**: `compose_page` for a full page, else `search` with `type` and `component` hints.
2. **Install**: `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a tool returned it (shadcn/ui parts install by bare name: `npx shadcn@latest add button --yes`).
3. **Read the API on your base**: `components.json` style `base-nova` is Base UI, `radix-nova` is Radix. Call `get_component([...componentsUsed])` once, then install 1 `get_examples` example and read its files.
4. **Adapt**: change data, copy and props; keep the structure and classes.
5. **Check**, after every UI edit, follow-ups included: `validate_usage` on any name you did not read, the self-checks C1 to C4 in [quality.md](./rules/quality.md), [anti-patterns.md](./rules/anti-patterns.md), `get_audit_checklist`, typecheck, lint. Hand back in the 5 lines quality.md gives.
Show every item's `previewUrl` whenever you list, recommend or install it.
## Rules that always apply
- **Owners**: tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` (destructive confirms `AlertDialog`), toasts `sonner`; never hand-roll.
- **Color**: semantic tokens for meaning; state colors through Badge or Alert variants (`@reui/badge` and `@reui/alert`, not shadcn's); raw palette only for categorical identity, always with its `dark:` pair.
- **Contrast**: never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
- **Shape**: no `rounded-*` except `rounded-full`, and no `shadow-*`, `h-*` or `px-*` on Button, Input, SelectTrigger, Card or Frame; pick `size` and `variant`. A panel is a Card or Frame, never a div with `rounded-lg border`.
- **Spacing**: `gap-*` on the parent, not `space-y-*`; `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections; title to description `gap-0.5`.
- **Type**: `text-sm` body, `text-xs` meta (controls keep their style's size), `font-medium` or `font-semibold`, headings `tracking-tight`, numbers `tabular-nums`; nothing below `text-xs` except illustrative mini-UI.
- **Motion**: installed components own their motion. Your own code uses `transition-colors` for hover and press, `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` to reveal, `duration-300` with the same curve at most (media hover zoom: 500), and `SPRING` (`{ type: "spring", visualDuration: 0.25, bounce: 0 }`) for drag and reorder. At most 1 entrance animation per view (a stagger counts as 1). Animate only `transform` and `opacity`, CSS before motion/react, never set state per frame; `<MotionConfig reducedMotion="user">` once at the root plus `motion-reduce:` on CSS, where reduced motion means a calm fade, not nothing.
- **Surfaces**: prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card. Icons come from 1 library, the `iconLibrary` in `components.json` (on Ultimate, 1 Motion Icon on the primary action is the exception).
- **States**: each data region has empty (1 action), loading (a `Skeleton` in the real layout) and error (Retry) states; light and dark work.
- **Never invent**: never write a prop, variant or `@reui/` name you did not read in an `api`, an installed file or a tool result.
## Topics
Always read `compose` before building a screen, and `quality` plus `anti-patterns` at the Check step. Besides those, read only the topic files the task touches, at most 4 (for example `color`, `motion`, `layout` and `components`).
Installed skill: open the file. MCP only: call `get_agent_skill` with the topic, or fetch `https://mcp.reui.io/skill/reui/<file>`.
| Topic | File | Read when |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| `compose` | [rules/compose.md](./rules/compose.md) | before building any screen or section |
| `workflow` | [rules/workflow.md](./rules/workflow.md) | `compose_page`, search hints, installing |
| `adapting` | [rules/adapting.md](./rules/adapting.md) | changing an installed block or example |
| `components` | [rules/components.md](./rules/components.md) | `data-grid`, `filters`, `kanban` and the rest |
| `color` | [rules/color.md](./rules/color.md) | any color, status, chart or dark mode choice |
| `theming` | [rules/theming.md](./rules/theming.md) | the styles, radius, density and sizes |
| `motion` | [rules/motion.md](./rules/motion.md) | any transition, animation or motion/react |
| `layout` | [rules/layout.md](./rules/layout.md) | house defaults, hierarchy, surfaces, states |
| `icons` | [rules/icons.md](./rules/icons.md) | icon imports, Motion Icons |
| `quality` | [rules/quality.md](./rules/quality.md) | the done gate: self-checks, a11y, hand-back |
| `anti-patterns` | [rules/anti-patterns.md](./rules/anti-patterns.md) | the Check step: drift pairs, stale habits |
| `registry` | [rules/registry.md](./rules/registry.md) | free vs Pro vs Ultimate, base vs radix |
| `cli` | [rules/cli.md](./rules/cli.md) | `components.json`, license key, install flags |
| `tools` | [tools.md](./tools.md) | MCP result shapes, errors, quota, commands |
## Plans
Free covers components, `c-*` examples, this skill and the MCP (a ReUI account and a daily allowance). Pro adds blocks. Ultimate adds Motion Icons. A plan miss is a normal answer with `locked: true`; keep building with what the plan covers.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "ReUI"
short_description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind."
default_prompt: "Use ReUI to build this UI with installed components and the ReUI motion and color rules."
+40
View File
@@ -0,0 +1,40 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- Never read `window`, `localStorage`, `location`, `Date.now()` or `Math.random()` in render or in a `useState` initializer; read them in an effect.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something, and the layout must still hold with long names, empty values and crowded data.
+117
View File
@@ -0,0 +1,117 @@
# Anti-patterns
Read this at the Check step, after the self-checks in [quality.md](./quality.md). Each entry: an `Incorrect:` line, then its `Correct:` fix. Detector: the self-check that prints it (C1 to C4), or `visual` when reading finds it. Source: the pilot run that wrote it, or `default` (a training-data habit).
## Pairs
### 1. Stagger on every row (Detector: C2; source: run-08)
Incorrect: `transition={{ delay: i * 0.05 }}` on all 50 rows of a table
Correct: 1 stagger of at most 6 items, `staggerChildren: 0.05`, never on table rows
### 2. Transition everything on a card (Detector: C1; source: runs 10, 15)
Incorrect: `className="rounded-xl border transition-all duration-300 hover:shadow-lg"`
Correct: `transition-colors` for a hover color; a lift is `transform` over a static shadow
### 3. Raw palette for a state (Detector: C3; source: runs 08, 15)
Incorrect: `<span className="text-green-600">Active</span>`
Correct: `<Badge variant="success-light">Active</Badge>`
### 4. Animated table rows (Detector: C1; source: runs 01, 08, 17)
Incorrect: `<motion.tr whileHover={{ backgroundColor: "var(--muted)" }}>`
Correct: `data-grid` rows, which already hover; never animate rows with motion/react
### 5. Hover scale on the default spring (Detector: C1; source: runs 01, 10)
Incorrect: `<motion.div whileHover={{ scale: 1.03 }}>` with no `transition`
Correct: CSS first: `transition-colors` for hover, `transition-transform active:scale-[0.98] motion-reduce:transition-none` for press
### 6. Entrance from scale 0 (Detector: C1; source: runs 10, 15)
Incorrect: `<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>`
Correct: `initial={{ opacity: 0, scale: 0.95 }}`: start at scale 0.95 or more and move 8px or less
### 7. Hand-built modal or toast (Detector: C4; source: run-15)
Incorrect: `<div className="fixed inset-0 z-50 bg-black/50">` as a modal
Correct: `Dialog`, `AlertDialog` for a destructive confirm, `sonner` for toasts; a palette: `<CommandDialog><Command><CommandInput /><CommandList>`, as `CommandDialog` holds no `Command`
### 8. Native select (Detector: C4; source: run-15)
Incorrect: `<select>` with `<option>` children
Correct: the shadcn `Select`, or `NativeSelect` when the native control is wanted
### 9. Hand-rolled table (Detector: C4; source: run-17)
Incorrect: `<table>` with mapped `<tr>` rows
Correct: `data-grid`: a `useTable({ features: dataGridFeatures })` instance passed as `table={table}`, columns typed `ColumnDef<DataGridFeatures, Order>[]`
### 10. Button inside a clickable wrapper (Detector: visual; source: run-02)
Incorrect: `<motion.button><Button>Save</Button></motion.button>`
Correct: the `Button` alone: 1 interactive element, and it already has a press state
### 11. Radix asChild on Base UI (Detector: C4; source: run-17, judge)
Incorrect: `<DialogTrigger asChild><Button>Open</Button></DialogTrigger>`
Correct: `<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>` on base-nova; a link: `<Button nativeButton={false} render={<Link href="/x" />}>`
### 12. Component defined inside render (Detector: visual; source: Haiku runs, judge)
Incorrect: `const Row = () => <div />` inside the page component
Correct: declare `Row` at module scope and pass it props, so React keeps its state
### 13. A panel inside a panel (Detector: visual; source: run-05)
Incorrect: `<FramePanel><Card>...</Card></FramePanel>`, or a Card inside a Card
Correct: FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame
### 14. Small badges (Detector: visual; source: runs 03, 04, 06, 07, 10, 13, 16, 18)
Incorrect: `<Badge variant="success-light" size="sm">Paid</Badge>`
Correct: `<Badge variant="success-light">Paid</Badge>` with `size` left off
### 15. A colored circle behind every KPI icon (Detector: visual; source: default)
Incorrect: `<div className="rounded-full bg-blue-100 p-2">` around each KPI icon
Correct: the number leads; at most 1 `text-muted-foreground` icon
### 16. Gradient text (Detector: visual; source: default)
Incorrect: `bg-linear-to-r from-violet-500 to-pink-500 bg-clip-text text-transparent` on a heading
Correct: `text-foreground` and `tracking-tight`; color carries meaning, not decoration
### 17. A dot or check for the current item (Detector: visual; source: default)
Incorrect: a dot or a `Check` icon beside the active nav item
Correct: `bg-muted` (`dark:bg-muted/50`) plus `aria-current`
### 18. framer-motion (Detector: C4; source: default)
Incorrect: `import { motion } from "framer-motion"`
Correct: `import { motion } from "motion/react"`
### 19. Clock or randomness in render (Detector: C4; source: default, near run-05)
Incorrect: `const [id] = useState(() => Math.random())`
Correct: `useId()` for ids; read the clock in an effect or an event handler
### 20. Placeholder data (Detector: visual; source: default)
Incorrect: "John Doe", "Acme Inc" and round figures like `$1,000`
Correct: specific, plausible data: "Mara Lind", `$1,284.50`, 37 open orders
## Your training data is out of date
| Old habit | In this stack |
| ----------------------------- | --------------------------------------------------------------- |
| `framer-motion` | `motion/react` |
| `<DataGrid data columns>` | `useTable({ features: dataGridFeatures })` plus `table={table}` |
| A fixed div modal | `Dialog` or `AlertDialog` |
| A hand-built toast | `sonner` |
| `w-5 h-5` | `size-5` |
| never `space-y-4` in new code | `flex flex-col gap-4` |
| `asChild` | `render` on base-nova |
+77
View File
@@ -0,0 +1,77 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the ReUI components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks and Motion Icons) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
`<name>` is the item name exactly as a ReUI tool returned it. The `@reui/` prefix is for ReUI items only:
- The shadcn/ui components ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. Install them from shadcn by bare name: `npx shadcn@latest add button --yes`.
- `badge` and `alert` exist in both registries: `@reui/badge` and `@reui/alert` are ReUI's components, bare `badge` and `alert` are shadcn's.
- In an item's `registryDependencies`, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both, so you never add them by hand.
- Templates are not registry items. Download a template from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs one.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the ReUI components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) need Ultimate.
- Templates are downloads from their page on reui.io, not installs: free ones need only a ReUI account, and each template's page says what the others need.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
If an install 404s, the name is not a ReUI item. Read the CLI's error message, which names the right install command when there is one, or look the name up with `get_install_command` or `search`.
+74
View File
@@ -0,0 +1,74 @@
# Color
Semantic tokens carry meaning; raw palette carries identity. Check every color in light and dark.
## Tokens
Surfaces, text and borders use your theme tokens (`background`, `foreground`, `card`, `muted`, `muted-foreground`, `primary`, `accent`, `destructive`, `border`, `ring`), so a theme change re-tints them. ReUI adds state tokens: `--success`, `--info` and `--warning` with their `-foreground`, `--destructive-foreground` (beside shadcn's `--destructive`) and `--invert` with `--invert-foreground` for inverted surfaces.
They are not part of shadcn's base theme. Installing `@reui/badge`, `@reui/alert` or `@reui/code-block` adds them to your global CSS; until one of those is installed, classes such as `bg-success` generate nothing. Check for `--success:` in your global CSS. If it is missing, add these values to `:root` and `.dark`, plus a `--color-<name>: var(--<name>)` line for each under `@theme inline`:
<!-- gen:tokens:start -->
| Token | `:root` | `.dark` |
| -------------------------- | -------------------------- | -------------------------- |
| `--destructive-foreground` | `var(--color-red-800)` | `var(--color-red-600)` |
| `--success` | `var(--color-emerald-500)` | `var(--color-emerald-500)` |
| `--success-foreground` | `var(--color-emerald-900)` | `var(--color-emerald-600)` |
| `--info` | `var(--color-violet-500)` | `var(--color-violet-500)` |
| `--info-foreground` | `var(--color-violet-900)` | `var(--color-violet-600)` |
| `--warning` | `var(--color-yellow-500)` | `var(--color-yellow-500)` |
| `--warning-foreground` | `var(--color-yellow-900)` | `var(--color-yellow-600)` |
| `--invert` | `var(--color-zinc-900)` | `var(--color-zinc-700)` |
| `--invert-foreground` | `var(--color-zinc-50)` | `var(--color-zinc-50)` |
<!-- gen:tokens:end -->
## Meaning vs identity
- Meaning (a state, emphasis, a surface, text) takes semantic tokens, through the Badge or Alert variant that already carries them.
- Identity (a chart series, a tag color, a team, an avatar) may use raw palette: ReUI's own lint keeps it on purpose. Always hand-pair it for dark, as in `text-violet-600 dark:text-violet-400`.
- **Incorrect:** `<span className="text-green-600">Active</span>` (raw palette for a state). **Correct:** `<Badge variant="success-light">Active</Badge>`.
## State recipes
- State badge: `<Badge variant="success-light">` from `@/components/reui/badge` (`@reui/badge`; shadcn's `components/ui/badge` has no `-light` variants or `size`), also `info-light`, `warning-light`, `destructive-light`, and `primary-light` for brand or pending. Neutral badge: `variant="outline"`. A solid `<Badge variant="success">` (white text) only when the user asks for a solid fill.
- Inline status text: `text-success-foreground dark:text-success`, the pair the ReUI Badge uses.
- Tinted surface: `border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success` (the Badge's `success-light` recipe).
- A message block: `<Alert variant="success">` from `@/components/reui/alert` (`@reui/alert`), also `info`, `warning` and `destructive`.
- Never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
## Contrast
<!-- gen:contrast:start -->
Light is `text-<tone>-foreground` on `background` and on the `bg-<tone>/10` tint; dark is `dark:text-<tone>` on `background` and on the `dark:bg-<tone>/15` tint. Ratios against the neutral theme, rounded down; body text needs 4.5:1.
| Tone | Light | Light on tint | Dark | Dark on tint |
| ------------- | ----- | ------------- | ---- | ------------ |
| `success` | 9.6 | 8.8 | 8.0 | 6.6 |
| `warning` | 8.6 | 8.1 | 10.3 | 8.1 |
| `info` | 11.0 | 9.7 | 4.4 | 3.9 |
| `destructive` | 8.3 | 7.0 | 6.8 | 5.7 |
Raw state color as light-mode text on `background`: `text-success` 2.4, `text-warning` 1.9, `text-info` 4.4.
<!-- gen:contrast:end -->
Info text in dark mode is under 4.5:1: where it must be read, write the words in `text-foreground` and color only the icon.
## Dark mode
- shadcn uses the class strategy: `@custom-variant dark (&:is(.dark *));` in your global CSS, and `.dark` on `<html>` turns it on.
- Tokens switch by themselves; raw palette needs its `dark:` pair.
- Check both themes before you hand back.
## Charts
Check your theme's `--chart-1` to `--chart-5` in both modes. The current shadcn themes keep the same chart colors in light and dark; some themes (reui.io's own among them) change hue. For a series whose identity must hold in both modes, use a palette var such as `var(--color-violet-500)`, as ReUI blocks do.
<!-- gen:charts:start -->
Same chart colors in light and dark: neutral, stone, zinc, mauve, olive, mist, taupe, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow. Changes hue between light and dark: reui.io's own theme.
<!-- gen:charts:end -->
+521
View File
@@ -0,0 +1,521 @@
# ReUI components
The ReUI components: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `color-picker`, `data-grid`, `date-selector`, `emoji-picker`, `event-calendar`, `file-type`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `signature-pad`, `sortable`, `stepper`, `time-picker`, `timeline`, `tree`. Examples and blocks are composed from these. `file-upload` is a ReUI component too, shipped as the `use-file-upload` hook rather than a primitive, so it is not in that list: install it as `@reui/use-file-upload`, which writes `hooks/use-file-upload.ts` (import `useFileUpload` from `@/hooks/use-file-upload`), call `get_component("file-upload")` for its API and `get_examples("file-upload")` for its free examples. There is no `@reui/file-upload`: the UI is a `c-file-upload-<n>` example, and one built on the hook installs it.
**Rule one: never guess a component's API.** Call **`get_component(name)`** for its inline `api` and share its `docsUrl` with the user, then **`get_examples(name)`** and copy a worked example. The contracts below are first-try orientation (required props, shape, the one gotcha); the `api` is the reference. A large `api` (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries: `sectionsOmitted` names what was dropped and the response's `next` hint pulls it back. `validate_usage` always checks the full API. No single block fits? Compose from components, 1 `get_examples` example each ([compose.md](./compose.md)).
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table with `useTable({ features: dataGridFeatures, ... })`. `dataGridFeatures` already bundles sorting, filtering, pagination, selection, expanding, pinning, resizing and faceting: no row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data: `manualPagination: true` and `rowCount: total` in `useTable`, `recordCount={total}`, and fetch with the table's `pagination` state (`DataGridPagination` changes `pageSize`).
- Columns are TanStack v9, `ColumnDef<DataGridFeatures, Order>[]`, never v8's `ColumnDef<Order>[]`; a sortable column's `header` renders `DataGridColumnHeader` (a string header cannot sort).
- Imports, each from `@/components/reui/data-grid/<file>`: `DataGrid`, `DataGridFeatures` and `dataGridFeatures` from `data-grid`, `DataGridColumnHeader` from `data-grid-column-header`, `DataGridTable` from `data-grid-table`, `DataGridPagination` from `data-grid-pagination`, `DataGridScrollArea` from `data-grid-scroll-area`; `useTable` and `ColumnDef` from `@tanstack/react-table`.
- States: `isLoading` renders skeleton rows from each column's `meta.skeleton` (a `ReactNode`: `<Skeleton className="h-4 w-20" />`); `emptyMessage` (a `ReactNode`) fills a grid with no rows: after a failed fetch, pass it the error with a Retry.
- Numeric column: `meta: { headerClassName: "text-end [&>div]:justify-end", cellClassName: "text-end tabular-nums" }`.
- Rows edited in place: `autoResetPageIndex: false` in `useTable`, or each write jumps back to page 1.
- In a Frame: `<FramePanel className="p-0">` around `DataGridScrollArea` and `DataGridTable`, plus `tableClassNames={{ base: "min-w-[720px]" }}` so on a phone the table scrolls inside the panel instead of squeezing.
```tsx
const columns = useMemo<ColumnDef<DataGridFeatures, Order>[]>(() => [
{ accessorKey: "total", header: ({ column }) => <DataGridColumnHeader title="Total" column={column} /> },
], [])
const table = useTable({ features: dataGridFeatures, data, columns })
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Never a raw `<table>` or hand-rolled pagination: the API covers sticky header, virtualization and row selection.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<div className="flex items-center justify-between">
<h3>{id}</h3>
<KanbanColumnHandle aria-label="Move column"><GripVertical /></KanbanColumnHandle>
</div>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks. `KanbanColumnHandle` is hidden until its column is hovered or it has keyboard focus, so keep the column title outside it.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()`: ids come from `createFilterIdFactory(seed)` seeded off `useId` (never minted in render), and `createFilterRule({ id })` takes one, `createFilterQuery()` none. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string, and `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches the current level and everything under it, `searchScope="global"` the whole tree from any level, and both annotate results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `virtualize` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels (paging, retry, `prefetch`). `CascaderFooter` pins commands below the list and `CascaderSubmenu` opens one as a side flyout. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/reui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window). `smooth` defaults to true: pass `smooth={!reduceMotion}` from motion/react's `useReducedMotion()`. Mark the active link `aria-current="location"` (track it with `onUpdate`); `"page"` is for route links.
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<FramePanel>Content</FramePanel>
<FrameFooter>Footer</FrameFooter>
</Frame>
```
**Gotcha:** the shell for data and tool-like surfaces. `spacing` (`xs` | `sm` | `default` | `lg`) sets panel padding and the gap between panels, so pick it instead of padding classes (the 1 exception: `<FramePanel className="p-0">` around a `data-grid`); `variant` is `default` | `inverse` | `ghost`; `stacked` joins panels with shared borders; `dense` removes the frame's own inset and gap (panels sit flush), and panel padding stays; `--frame-radius` is the one radius knob (panels derive `--frame-panel-radius`).
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light">Success</Badge>
<Badge variant="outline">Draft</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl` (keep the default), `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes: `<tone>-light` for a state, `outline` for a neutral label; an omitted `variant` is the solid primary fill.
## color-picker
**Required:** none (`<ColorPicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<ColorPicker value={color} onValueChange={setColor} alpha>
<ColorPickerTrigger />
<ColorPickerContent>
<ColorPickerPanel>
<ColorPickerArea />
<ColorPickerSlider channel="hue" />
<ColorPickerSlider channel="alpha" />
<ColorPickerFields />
</ColorPickerPanel>
</ColorPickerContent>
</ColorPicker>
```
**Gotcha:** the value is a CSS color string in `format` (`"hex"` default, `"rgb"`, `"hsl"`, `"hsb"` or `"oklch"`), `null` when empty, never an object. `onValueChange` fires on every drag move, so save in `onValueCommit`. Without `alpha` the value is always opaque. A bare `ColorPickerPanel` inside the root is an inline picker; `ColorPickerInput` replaces the trigger with a typable field.
## emoji-picker
**Required:** none; wire `onEmojiSelect` to insert a pick, or `value` + `onValueChange` to use it as a field.
**Shape:**
```tsx
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
<EmojiPickerTrigger />
<EmojiPickerContent>
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
```
**Gotcha:** the emoji data is fetched at runtime from a pinned jsDelivr path (`EMOJI_PICKER_DATA_URL`): under a strict Content Security Policy allow it in `connect-src`, self-host with `dataUrl`, or pass `data` (from `normalizeEmojibaseData`). Store the pick's `value` (the native emoji in its skin tone, or `:shortcode:` for a custom one). `multiple` makes the value a list, capped by `max`.
## file-type
**Required:** none; pass `type` (an extension, a family name or any string).
**Shape:**
```tsx
<FileType type="pdf" />
<FileType type={getFileType(file.name)} size="sm" aria-hidden="true" />
```
**Gotcha:** one `span` with `role="img"`, no children, no `"use client"` (fine in a Server Component). Resolve a file name, path, URL or MIME type with `getFileType`, never by splitting on the dot. `variant`: `outline` (default) | `solid` | `light` | `mono`; `size`: `mini` | `xs` | `sm` | `default` | `lg` | `xl` (18 to 56px); recolor with a `text-*` class. Pass `aria-hidden="true"` when the file name sits beside it.
## signature-pad
**Required:** `SignaturePad` > `SignaturePadArea`
**Shape:**
```tsx
<SignaturePad value={strokes} onValueChange={setStrokes}>
<SignaturePadArea>
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
```
**Gotcha:** the value is an array of strokes (`{ points: [x, y, size][], color? }`), not an image: store it as JSON, and export with `useSignaturePad()` or `apiRef` (`toSVG`, `toDataURL`, `toBlob`) or `SignaturePadSave` (`format`, `onSave`). `onValueChange` fires per finished stroke, never per pointer move. Size the area with a class (`h-40` by default); an overlay in it that is not a control needs `pointer-events-none`. Drawing needs a pointer, so offer a typed name where a signature is required.
## time-picker
**Required:** none (`<TimePicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<TimePicker value={time} onValueChange={setTime}>
<TimePickerTrigger />
<TimePickerContent>
<TimePickerColumns />
<TimePickerFooter>
<TimePickerNow />
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
```
**Gotcha:** the value is a 24-hour `"HH:mm"` string (`"HH:mm:ss"` with `granularity="second"`) or `null`, never a `Date`, whatever `hourCycle={12}` shows. `minuteStep`, `min`, `max` and `isTimeDisabled` narrow the options; keep `isTimeDisabled` stable (module scope or memoized). `requireConfirm` holds picks as a draft until OK. `TimePickerInput` replaces `TimePickerTrigger` for a typable field, never beside it.
## base vs radix - write for the project's base
Every component ships a `base` (Base UI) and a `radix` (Radix UI) build under 1 name, and the CLI installs the one the project uses. Read `components.json` `style`: the part before the first `-` is the base (`base-nova` is **Base UI**, `radix-nova` is **Radix UI**). Write against that base; the deltas mirror shadcn's:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
Installed files and `c-*` examples are already in your base: reuse their wiring. When an `api` or example shows the other base's shape, translate it (or `validate_usage` to confirm).
+62
View File
@@ -0,0 +1,62 @@
# Compose a screen
Read this before you build any screen or section, then write the router's 7-line plan in your reply (Surface, Archetype, Container family, Density, Regions, States, Motion) and build exactly that plan. Style it with the topics the screen touches: [layout.md](./layout.md) (house defaults), [color.md](./color.md), [motion.md](./motion.md).
If the brief names a layout or a motion (a left nav, 2 entrances), follow the brief and name the default you overrode. Several entrances run as 1 stagger: at most 6 items, 50ms apart, never on table rows or lists of 20 or more.
## Surface
- **Operational**: a screen people work in every day (dashboard, records, settings, admin tools). Frame-led, dense (`size="sm"` on controls that take it), no hero, no page-load motion.
- **Marketing**: a page a visitor reads once (landing, pricing, features). Card-led sections on the spine below, default density.
- **Flow**: a task people finish and leave (sign in, onboarding, checkout). 1 centered Card or a Dialog, 1 primary action per step.
## Archetypes: pick 1 per screen
- **Vertical rail settings** (3 to 6 sections): a section rail on the left, 1 content column, 1 section at a time; the rail turns horizontal below `md`.
- **Horizontal tabs** (5 to 8 views of 1 object): a full-width `Tabs` rail under the header that scrolls sideways when it overflows, 1 content column.
- **Frame stack**: stacked Frames, each 1 focused group of settings or records; a flat page split by `Separator` rows when it should feel lighter.
- **Table-backed management**: header, toolbar (search, `filters`), then a `data-grid` with row actions and pagination, all in 1 Frame.
- **Dashboard grid**: a KPI row, then 1 or 2 chart or list panels, then the table.
- **Board**: header and filters, then a `kanban` board; `KanbanBoard` stacks its columns below `sm`, and a wider board scrolls inside its own region.
- **Record detail**: header (title, status `Badge`, actions), a main column of sections, a side column of facts that stacks under it below `lg`.
- **Marketing spine**: optional `Badge` eyebrow, headline (the only loud element), 1-clause subhead, action row (1 primary `Button`, a second `variant="outline"`, a third a text link), specific proof beside it ("27 reviews"), media. Omit parts; never reorder them.
- **Auth card**: 1 centered `Card` at `max-w-sm`: title, fields, 1 full-width primary `Button`, a text link to the other flow.
- **Wizard dialog**: a `Dialog` holding a `stepper`, 1 step visible, Back (`variant="outline"`) and Next (primary) in its footer.
An app shell around any of them is the shadcn `Sidebar`, which turns into a `Sheet` below `md` by itself.
## Fill each region in this order
1. The ReUI component that owns the job: the router's Owners (`data-grid`, `filters`, `kanban`), `useFileUpload` from `@/hooks/use-file-upload` for file drop, or the one whose contract in [components.md](./components.md) matches.
2. A shadcn/ui component (`Tabs`, `Dialog`, `AlertDialog`, `Sheet`, `Empty`, `Skeleton`, `sonner`).
3. A free `c-*` example from `get_examples` that already composes it: install it and adapt.
4. Your own composition of those parts. Never a hand-rolled table, modal or tabs.
Import ReUI parts file by file: `data-grid`, `filters`, `cascader`, `event-calendar` and `gantt` are folders with no index file. `data-grid`: the import line in [components.md](./components.md). `filters`, each from `@/components/reui/filters/<file>`: `Filters` from `filters`, `createFilterQuery` and `flattenFilterConditions` (1 argument, the query) from `filters-query`, the types `FilterField` and `FilterQuery` from `filters-types`.
## Wiring that works
- `data-grid`: columns are `ColumnDef<DataGridFeatures, Order>[]`, a sortable header renders `DataGridColumnHeader`, and server paging needs `manualPagination: true` and `rowCount`.
- `filters`: apply each condition by its `operator`. Negate swaps a rule to its inverse (`is` and `is_not`, `contains` and `not_contains`, `is_any_of` and `is_none_of`, `empty` and `not_empty`) and sets `negated` only when there is none: honour both.
- A Base UI `Button` that navigates: `<Button nativeButton={false} render={<Link href="/x" />}>`.
- shadcn's `CommandDialog` has no `Command` inside, unlike older shadcn: wrap `CommandInput` and `CommandList` in one. `CommandItem` search matches its `value`: omit it (text is used) or use the label; ids in `onSelect`.
## Headers
A page or panel header puts the title (1 to 3 words) and a 1-clause description on the left and the actions on the right: 1 primary `Button`, the rest `variant="outline"`, row actions `variant="ghost"`.
## Responsive
- No horizontal page scroll at 390px: grids drop to 1 column, side columns stack, `min-w-0` on every shrinking parent.
- A KPI row is `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4`.
- Every header and toolbar action stays reachable at every width: a control hidden below a breakpoint moves into a menu shown only there.
- A table scrolls sideways inside its Frame, never the page (the `data-grid` contract in [components.md](./components.md)).
## States and motion
- Each data region owns its states: empty (shadcn `Empty` with a title, 1 line and the primary action), loading (`Skeleton` in the real layout; `data-grid` takes `isLoading`) and error (inline in the region, with Retry). Never 1 spinner for the whole page.
- Motion: none, or the 1 entrance per view; installed parts keep their own motion, and hover and press follow [motion.md](./motion.md). An Alert, field error, notice or data that appears after an action or a load is a reveal, not an entrance: fade it in with the reveal row.
## Remove first
Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments. Add a region only when the brief needs it.
+43
View File
@@ -0,0 +1,43 @@
# Icons (ReUI delta over shadcn)
Use the project's configured `iconLibrary`, put `data-icon="inline-start"` on an icon before the label inside `Button` (`data-icon="inline-end"` on one after it), put no sizing classes on icons inside components, and pass icons as components, not string keys. ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. The shadcn CLI installs each item's icons in the library named by `iconLibrary` in `components.json` - you swap nothing. When `iconLibrary` is not set, the CLI falls back to `lucide`, so the icons arrive as `lucide-react` imports. If that is not the project's library, set `iconLibrary` before installing, or change the **import source and component name** to your library, keeping the same icon-name semantics (each key below is an `iconLibrary` value):
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remixicon` -> `@remixicon/react`
- `hugeicons` -> icons from `@hugeicons/core-free-icons`, rendered through `HugeiconsIcon` from `@hugeicons/react`
Don't assume `lucide-react`: read `iconLibrary` from `components.json`, and when it is unset, check which icon package the project already uses.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled). Every icon installs from the default path; some also have a real hover animation (motion/react) on the animated path:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static, every icon
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated, only when the icon has installAnimated
```
The animated path answers for every icon, but for an icon without a real animation it installs the same static drawing, so only use it when the icon carries `installAnimated`.
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. On Ultimate, a Motion Icon on the primary action is the 1 hover flourish (see [motion.md](./motion.md)).
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a real hover animation; a concept with none comes back empty and `animatedNote` says so.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` only when the icon really animates - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (the icon's own page) and a `thumbnail` (its 4 styles side by side) - **show or share them** so the user can SEE the icon before installing, and look at the thumbnail yourself before trusting a name.
- Several icons can fit one concept. Before installing, read `get_icon`'s `whenNotToUse` (each entry names the icon to use instead), and use its `label` as the `aria-label` of an icon-only button.
- Icon names were revised in the icons v2 update. An old name (`save-2`, `abstract-18`) still answers in `get_icon`, `get_install_command`, `validate_usage` and `get_thumbnail` with the icon it became and `renamedFrom`, and still installs that same drawing; write the current name in new code. That includes names renamed because they misdescribed the drawing (`watch` installs a sand timer, `chevrons-down` points up), so look at the `thumbnail` before trusting an old name. `search_icons` answers an old name the same way, unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word, and `oldNameNotes` names the icon the old name installs. `search` ranks by current names only, so look an old name up with `get_icon`, never `search`. Only `tag` (it drew a money bag) no longer answers: the reply names the icons to use instead.
The `icon-stack` component composes multiple icons into a stacked display.
+56
View File
@@ -0,0 +1,56 @@
# Layout: house defaults, hierarchy, spacing, surfaces and states
## Point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
**The bar:** before you finish, ask: would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content? If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
## House defaults
<!-- gen:house-defaults:start -->
- **Spacing ladder**: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections, `gap-0.5` title to description. Deviate when: skip a level if you like, never invert them.
- **Panel title**: no size class on `FrameTitle` or `CardTitle`. Deviate when: never; `FrameTitle` ships `text-sm font-semibold`, `CardTitle` follows the installed style.
- **Meta text**: `text-xs`. Deviate when: never smaller, except inside illustrative mini-UI artwork.
- **Frame spacing**: `spacing="default"`, written out. Deviate when: `sm` in a sheet, dialog, drawer or popover, or for a dense data grid in a panel; `xs` or `lg` only with a stated reason.
- **Leading row icon**: `size-4`. Deviate when: never in a glyph column; `Spinner` ships `size-4`, so a loading swap keeps the width.
- **Icon inside a control**: no size class; the control sizes it per style and size. Deviate when: never pin `size-*` on an svg inside `Button`, `InputGroupButton`, `InputGroupAddon`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `SelectItem`, `CommandItem`, `Toggle`, `ToggleGroupItem`, `IconTile` or `MarkerIcon`.
- **Badge size**: `default` (leave `size` off). Deviate when: `lg` or `xl` only when the user asks; never `sm` or `xs`.
- **Neutral badge**: `variant="outline"`. Deviate when: never `secondary`, `default` or `primary-light` for an id, tag, version, count or Draft/Beta label; inline after the title, never `ml-auto`.
- **State badge**: `<tone>-light`: `success-light`, `info-light`, `warning-light`, `destructive-light`; `primary-light` for brand or pending. Deviate when: never the solid tone, `secondary` or `outline` for a state; 1 state badge per row.
- **Solid badge**: never unless asked; always name a variant (an omitted one is the solid primary fill). Deviate when: only when the user asks for a solid fill (`default`, `secondary`, `success`, `info`, `warning`, `destructive`, `focus`, `invert`).
- **Slot padding**: the primitive's own inset; set density with `Frame spacing=` or `Card size="sm"`. Deviate when: never a padding class on `CardHeader`, `CardContent`, `CardFooter`, `FrameHeader`, `FramePanel`, `FrameFooter`, `SheetHeader`, `SheetContent`, `SheetFooter`, `DialogHeader`, `DialogContent`, `DialogFooter`, `PopoverContent`, `DropdownMenuContent`, `TabsContent` or `AccordionContent`.
- **Inline separator**: `<span aria-hidden className="bg-muted-foreground/40 size-1 shrink-0 rounded-full" />`. Deviate when: never a typed `·` between rendered segments; a literal dot only inside 1 data string.
- **List rows**: dense, auto height; a row with no second line stays 1 tight line. Deviate when: never uniform height.
- **Current list item**: ghost hover fill (`bg-muted`, `dark:bg-muted/50`) plus `aria-current`. Deviate when: never a dot or a check mark.
<!-- gen:house-defaults:end -->
## Hierarchy
- 1 focal point per card or panel: the dominant metric or task, then its label, then detail. Borders, separators and surfaces make 2 to 3 bands, never equal weight everywhere.
- Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments.
## Spacing and copy
- Siblings take the parent's `gap-*`, never their own margins; a heading sits nearer its own content than the section above it. Never pad an operational surface like a marketing page.
- Titles 1 to 3 words, a description 1 clause (about 8 words), a context line 2 segments at most, labels and buttons 1 to 2 words, a toast title 4 words at most. Copy that wraps at desktop width is over budget: cut it, never shrink the type.
## Surfaces
- `Frame` for data and operational surfaces (tables, lists, tool panels); `Card` for bounded sections and marketing.
- Prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card.
- A `data-grid` inside a Frame is the one place for `<FramePanel className="p-0">`; elsewhere no padding on slots. This is the 1 exception to the Slot padding row above.
- 1 archetype per screen (operational ones and the marketing spine): [compose.md](./compose.md) lists each with its regions.
## Type and buttons
- A KPI value is `text-2xl font-semibold tracking-tight tabular-nums`.
- `size="sm"` on dense surfaces, `variant="outline"` for secondary actions, `ghost` for row actions; 1 primary per region, 1 variant per toolbar.
- A toolbar separator is `<Separator orientation="vertical" className="my-auto h-4" />`; at a 390px viewport a toolbar stays on 1 row.
## States and responsive
- Empty: a short message plus the primary action. Loading: a `Skeleton` in the real layout, never a centered spinner. Error: inline with a retry.
- No horizontal page scroll at 390px: reflow to 1 column instead of shrinking; `min-w-0` on the shrinking parent and `truncate` on single-line labels.
+80
View File
@@ -0,0 +1,80 @@
# Motion
## Installed motion: never retime it
<!-- gen:installed-motion:start -->
| Installed part | Duration |
| ------------------ | ----------------- |
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
<!-- gen:installed-motion:end -->
Never add or change `duration-*`, `ease-*`, `animate-*` or `transition-*` on installed parts (`components/ui`, `components/reui`); Button already presses with `active:not-aria-[haspopup]:translate-y-px`.
## House motion: your own code
<!-- gen:house-motion:start -->
| Use | Tailwind |
| ------------------------- | ------------------------------------------------------- |
| Hover, press, color | `transition-colors` (150ms, no duration or ease class) |
| Reveal or expand | `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` |
| Large surface (ceiling) | `duration-300` with the same curve |
| An overlay you build | `duration-100` with the same curve |
| Media hover zoom | `duration-500` with the same curve, `scale-105` at most |
| Width tied to the sidebar | `duration-200 ease-linear` |
<!-- gen:house-motion:end -->
- Press on a custom surface (not Button): `transition-transform active:scale-[0.98] motion-reduce:transition-none`.
- Hover color plus press on 1 element: `transition-[color,background-color,border-color,scale]` (`transition-colors` and `transition-transform` override each other; `scale-*` sets `scale`).
- motion/react takes the same values in seconds: `{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }`; hover `{ duration: 0.15 }`.
- Drag, reorder and layout use `const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }`. Define `SPRING` only when something drags, reorders or toggles layout.
## Hard rules
- Animate `transform` and `opacity`; color only for hover and press. Never `transition-all`; width, height, top or left only in the sidebar row.
- Never `animate-bounce` or a spring with bounce above 0. `ease-linear` only for progress, spinners, loops and the sidebar; never Tailwind's `ease-in`, `ease-out` or `ease-in-out`.
- `motion/react` only: never framer-motion, GSAP or react-spring.
- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its `Skeleton`) is a reveal, not an entrance: fade it in with the reveal row.
- Never add named `duration-*` utilities or redefine `--ease-out`.
- Loading: a `Skeleton` (it already pulses) in the real layout; a spinner only inside a busy button.
- Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its `installAnimated` command; never retime it.
## Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare `transition` (it also animates box-shadow and filter). (`reui/no-paint-animation`; 240px: judgement (no check))
- A hover lift is `transform` over a static shadow, or a shadow layer whose `opacity` fades; never a transitioned `hover:shadow-*`. (`reui/no-paint-animation`)
- CSS first: hover, press, reveals and ambient loops are Tailwind transitions, `tw-animate-css` or `@keyframes`. motion/react only for exit on unmount, drag, springs that follow input, `layout` and scroll-linked values. (judgement (no check))
- In motion/react, `x`, `y`, `scale`, `rotate`, `pathLength` and every SVG target run on the main thread: never `repeat: Infinity` on them (loop with CSS `@keyframes`), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element without `repeatDelay`, `repeatType: "mirror"` or `onUpdate`. (`reui/no-main-thread-loop`)
- SVG geometry (a rect's `width`, a circle's `r`) does not reflow the page but runs in motion's JS loop: one-shot only. (`reui/no-main-thread-loop`)
- Never set React state per frame (requestAnimationFrame, `useAnimationFrame`, `onUpdate`, scroll or pointermove listeners): bind styles to `useMotionValue`, `useTransform` or a CSS variable on the element. (`reui/no-frame-setstate`)
- A JS-driven loop (motion `repeat`, requestAnimationFrame, canvas) pauses offscreen with `useInView` or IntersectionObserver; `content-visibility: auto` never stops script, and a setInterval loop also pauses on `visibilitychange`. (`reui/loop-offscreen`)
- No permanent `will-change`: no `will-change-*` class or `willChange` style, except during a drag, removed when it ends. (`reui/will-change`)
- Stagger 50ms or less per step (`staggerChildren: 0.05`), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals use `whileInView` with `viewport={{ once: true }}`. (`reui/stagger-budget`; item count and tail: judgement (no check))
- A height reveal (`grid-template-rows`) starts only on a click or key press, runs 200ms or less, 1 at a time; never `initial={{ height: 0 }}`. (`reui/no-layout-transition`)
- The motion `layout` prop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through `@reui/sortable` or `@reui/kanban`. (judgement (no check))
- AnimatePresence `mode="wait"` only with an exit of 150ms or less; `initial={false}` around content present at first render. (`reui/stagger-budget`)
- Never hide the hero or the largest heading behind a motion/react `initial` at opacity 0 (hidden until JavaScript runs). (judgement (no check))
- At most 3 ambient loops per view: CSS `@keyframes` on transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (a `content-visibility: auto` wrapper with `contain-intrinsic-size: auto <h>` where nothing paints outside it, or IntersectionObserver toggling `animation-play-state`). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
## Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (`reui/reduced-motion`)
- The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (`reui/reduced-motion`)
- In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (`reui/reduced-motion`; half the viewport: judgement (no check))
- `<MotionConfig reducedMotion="user">` once at the app root (its default ignores the OS) AND a `motion-reduce:` path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (`reui/reduced-motion`)
- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a `transform` string keep animating, so a loop or those keys branch on `useReducedMotion()` and rest on the final value. (`reui/reduced-motion`)
- `useReducedMotion()` is null on the server: never let it pick markup (JSX, className, `initial`) or reach `animate` under `initial={false}`; branch only animate, transition, `while*` or exit values. (`reui/reduced-motion-ssr`)
- No SMIL (`<animate>`, `<animateMotion>`, `<animateTransform>`); canvas and requestAnimationFrame loops draw 1 resting frame under `prefers-reduced-motion: reduce`. (`reui/no-smil`, `reui/reduced-motion`)
- Smooth scroll only under `motion-safe:scroll-smooth`. A focus ring appears at once: never `transition-all`, `transition-shadow` or a bare `transition` on it. (`reui/reduced-motion`, `reui/focus-transition`)
- Loaders and typing dots keep their opacity pulse; every decorative loop gets `motion-reduce:animate-none`, never an inline `style={{ animation }}` (it beats the class). (`reui/reduced-motion`)
- Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (`reui/pausable-autoplay`)
- Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))
+57
View File
@@ -0,0 +1,57 @@
# Quality gates (security, accessibility, scroll, self-checks)
This is the **done gate**: before you call any ReUI work finished, call `get_audit_checklist` and pass every item below (plus the bar and the states in [layout.md](./layout.md)). Then run the self-checks, typecheck and lint, compare with [anti-patterns.md](./anti-patterns.md), and hand back in the 5 lines below.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance. Hover on a display-only card: make the card a link to its detail, or give it no hover.
- `aria-current="page"` on the current route link, `"location"` on the current in-page section link.
- Destructive confirms use `AlertDialog`, not `Dialog`, and cannot be dismissed while pending.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship their keyboard behavior; never re-implement it.
- Announce async UI: loading and error messages use `role="status"` or `aria-live`.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
## Self-checks
After every UI edit, run these with ripgrep on `<files>`: each file you wrote or changed, by path, never `components/ui` or `components/reui`. A clean check prints nothing; a hit prints `file:line:code`. The last filter drops comment lines.
```sh
# C1 banned motion: transition-all, bare or paint transitions, layout keys, bounce, overshoot, scale 0, hover and tap gestures, will-change
rg -nH 'transition-all|transition(-property)?: *[^;]*\b(all|width|height|top|left|margin|padding|box-shadow|filter)\b|[\x27\x60" ]transition( +(duration|ease|delay|hover|focus|active|group|motion)|[\x27\x60"])|transition-shadow|transition-\[[^\]]*\b(width|height|top|left|right|bottom|inset|margin|padding|filter|radius|background-position|background-size|all)\b|animate-bounce|will-change|willChange|bounce: *(0?\.0*[1-9]|[1-9])|[\x27"](backIn|backOut|backInOut|anticipate)[\x27"]|(cubic-bezier\(|ease: *\[)([^,]*,|([^,]*,){3})[ _]*(-|1\.[0-9]*[1-9]|[2-9])|scale: *0 *[,}]|\bscale-0\b|while(Hover|Tap|Focus)=|(initial|animate|exit)=\{\{[^}]*\b(width|height|maxHeight|top|left|margin|padding|boxShadow|filter|backdropFilter|borderRadius)[A-Za-z]*:' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 off-scale motion: a duration off 0, 100, 150, 200 and 300 (duration-500 only on a media hover zoom), ease-in or ease-out, a stagger step above 0.05
rg -nH 'duration-([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})\b|duration-\[|\b([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})ms\b|(^|[^0-9.])(0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)|1\.[0-9]+)s\b|duration: *([1-9]\b|0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)\b)|\bease-(in|out|in-out)\b|(staggerChildren: *|stagger\( *)(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *[^,}]*\b(i|index|idx)\b *\* *(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *(0?\.0[6-9]|0?\.[1-9]|[1-9])[0-9.]* *\* *\b(i|index|idx)\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 read by hand: a duration or delay that is not a number, every index delay (allowed: 0.05 or less on the first 6 items, as Math.min(i, 5) * 0.05, never on table rows)
rg -nH '([dD]uration|[dD]elay): *[^0-9 .]|delay: *[0-9.]+ *\*' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C3 state color: raw palette on a line that names a state
rg -nH '\b(text|bg|border|ring|fill|stroke)-(red|rose|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet)-[0-9]{2,3}\b' <files> | rg -i ':[0-9]+:.*\b(success|paid|active|completed?|done|approved|online|healthy|live|error|failed|critical|overdue|declined|rejected|cancell?ed|blocked|expired|warning|pending|degraded|due|info|processing|running|scheduled|queued)\b' | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 structure: a raw table, select or fixed overlay, framer-motion, Math.random() or Date.now()
rg -nH '<table\b|<select\b|fixed inset-0|from ["\x27]framer-motion|Math\.random\(|Date\.now\(' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 on a base-* style only (skip on radix-*): Radix asChild
rg -nH '\basChild\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
```
Fix every hit, run again, stop after the second run and list what is left with file and line. A C4 hit stays only when a comment in the file says why. These also stay, named on hand-back line 2: C1 on the motion.md sidebar row or on will-change set in a drag handler and removed when it ends; C2 on a media hover zoom; `Math.min(i, 5) * 0.05`.
No `rg` (a missing one also prints nothing): run each first pattern with your own search tool, skipping comment lines. No shell: read each changed file for what each `#` line names.
## Hand-back
End your reply with these 5 lines:
1. Installed: each item with its `previewUrl`.
2. Motion: each animated element and its [motion.md](./motion.md) table row, or "none".
3. States: covered, or which are missing.
4. Checks: C1 to C4, screenshots or "not visually verified", the audit checklist, typecheck, lint; each one run, or "not run" and why.
5. Deviations: any rule broken or check hit left, with file:line and why.
+44
View File
@@ -0,0 +1,44 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the ReUI components with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (a full dashboard or settings page). Pro or Ultimate license at install. Its surfaces, text and borders use your theme tokens; its category accents (chart series, tags, avatars) are fixed palette colors paired for light and dark.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`), plus a hover-animated variant (`@reui/icons/animated/<style>/<name>`) only for icons that carry `installAnimated`. Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
ReUI items install through the shadcn CLI as `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a ReUI tool returned it (`search`, a getter, `get_install_command`). The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## ReUI items vs shadcn/ui components
- **ReUI items** (the components, `c-*` examples, blocks, Motion Icons) install as `@reui/<name>`.
- **shadcn/ui components** that ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. They install from shadcn by bare name: `npx shadcn@latest add button --yes`.
- **`badge` and `alert` exist in both registries.** `@reui/badge` and `@reui/alert` are ReUI's components (the extra variants in [components.md](./components.md)); bare `badge` and `alert` are shadcn's.
- **In an item's `registryDependencies`**, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both for you.
- **Templates are not registry items.** Download one from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs a template.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks take surfaces, text and borders from your theme tokens, so a theme change re-tints them; category accents stay fixed by design.
## Free vs premium
- **Free, no license key:** the ReUI components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate) and Motion Icons (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
- **Templates** are downloads, not installs: free ones need only a ReUI account, and each template's page on reui.io says what the others need.
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+37
View File
@@ -0,0 +1,37 @@
# Theming: styles, radius, density and sizes
## Read your style
`components.json` `style` is `<base>-<style>`: the part after the first `-` names the style (`base-nova` is nova on Base UI). The style is baked into `components/ui` and `components/reui` at install, so the installed files hold your real values: read your installed `button.tsx` (usually `components/ui/button.tsx`) for heights, radius and padding before you build anything that sits beside a control.
## The style ladder
<!-- gen:style-ladder:start -->
| Style | Button `sm` / default / `lg` | Button radius | Card radius, shadow | Card spacing | Button text |
| ----- | ---------------------------- | -------------- | ---------------------------------------------------- | ------------ | -------------------- |
| vega | `h-8` / `h-9` / `h-10` | `rounded-md` | `rounded-xl`, `shadow-xs` | 6, sm 4 | `text-sm` |
| nova | `h-7` / `h-8` / `h-9` | `rounded-lg` | `rounded-xl` | 4, sm 3 | `text-sm` |
| maia | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-2xl` | 6, sm 4 | `text-sm` |
| lyra | `h-7` / `h-8` / `h-9` | `rounded-none` | `rounded-none` | 4, sm 3 | `text-xs` |
| mira | `h-6` / `h-7` / `h-8` | `rounded-md` | `rounded-lg` | 4, sm 3 | `text-xs/relaxed` |
| luma | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-4xl`, `shadow-md` | 6, sm 4 | `text-sm` |
| sera | `h-9` / `h-10` / `h-11` | `rounded-none` | square, `shadow-sm` | 8, sm 5 | `text-xs`, uppercase |
| rhea | `h-7` / `h-8` / `h-9` | `rounded-2xl` | `rounded-[min(var(--radius-4xl),24px)]`, `shadow-sm` | 5, sm 4 | `text-sm` |
<!-- gen:style-ladder:end -->
Card spacing is the `--card-spacing` step (`--spacing(n)`) a Card pads with; `Card size="sm"` uses the sm step. The default Button text weight is `font-medium` (sera: `font-semibold`).
## What the style owns
- Never set what the style owns on its parts: radius (except `rounded-full`), height, horizontal padding, shadow, ring or font size. Pick `size` and `variant` instead.
- A panel is a `Card` or a `Frame`, never a div with `rounded-*`, `border` and `bg-card`; set density with `Card size="sm"` or Frame `spacing`.
- A bespoke element beside a control (a chip, a custom trigger) takes its height and radius from that control's row above.
- A focusable control you build copies the focus classes of your installed `input.tsx`.
- A variant before a class: `className` is for layout (placement, width, gap). `size-*` when width equals height, `cn()` for conditional classes (never a template literal), a scale step before an arbitrary value (`w-60`, not `w-[240px]`), no inline `style` for color or spacing, and no `z-index` on dialogs, sheets or popovers.
- Change the look through the theme CSS variables in `:root` and `.dark` (`--radius`, `--primary` and the rest) or with a style switch (a new `style` in `components.json`, which means reinstalling the components; ask the user first). Never edit installed files to restyle them.
## Components with no per-style values
`event-calendar`, `gantt`, `icon-stack`, `kanban`, `rating`, `scrollspy`, `sortable` and `timeline` install the same file in every style. Their look comes from your theme tokens and the parts you put inside them (a `Card` in a kanban column follows the style), so never copy another style's radius onto them.
+66
View File
@@ -0,0 +1,66 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## When to reach for ReUI vs plain shadcn
- A full page or section (dashboard, billing, auth, pricing, settings): `compose_page` first (step 1).
- A data table with sorting, filtering, pagination or virtualization: the `data-grid` component, never a hand-rolled `<table>`.
- A drag-and-drop board: the `kanban` component.
- Column filters, a date range, a tree, a stepper and the like: the matching ReUI component.
- A single generic control shadcn already has (Button, Dialog, Select): plain shadcn.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent)` FIRST (`sections`, 1 to 10, caps how many sections, default 8), before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (a ReUI component), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid" })` (on a free account use `type: "example"`).
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Pick with the guidance, not the title.** Look-alikes are common (dialog, sheet and drawer; dozens of data-grid examples and app shells). Before installing, read the item's guidance: `get_component` returns `whenToUse` and `whenNotToUse`, `get_example` and `get_block` return `bestFor` (what sets the item apart from its siblings), `agentNotes` and `whenNotToUse`, and a block adds `categoryGuidance` (its category's key note), and `get_examples` rows carry `bestFor`. Each `whenNotToUse` entry names the item to use instead and its `type` (`component` -> `get_component`, `example` -> `get_example`, `block` -> `get_block`, `block-category` "<group>/<category>" -> `search({ query, type: "block", category: "<category>" })`); when the user's situation matches its `when`, switch. Apply every `agentNotes` line before you ship: it names the real identifiers to replace or wire (sample data, stubbed actions, required props). An item with no guidance yet has none of these keys; fall back to its description and thumbnail.
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**Show the preview.** List every item with its `previewUrl` (a live preview page). Where a result carries `thumbnail` (blocks, `c-*` examples and icons), show the image as markdown linked to `previewUrl`, so the user sees it before you install.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. Write against your project's base (see [components.md](./components.md)). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to the active theme: semantic tokens for anything that carries meaning, raw palette only for categorical identity and always with its `dark:` pair (see [color.md](./color.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, motion that leaves installed components alone, and mobile-first responsiveness (see [layout.md](./layout.md) and [motion.md](./motion.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+74
View File
@@ -0,0 +1,74 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; Pro unlocks premium blocks, Ultimate adds Motion Icons, and either removes the daily request limit by default. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (`sections` is a number, 1 to 10: the most sections to return, default 8); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Only some icons have a real hover animation: `animated: true` returns only those, and a concept with none comes back empty with an `animatedNote`. Each icon carries a `thumbnail` of its 4 styles; look at it before trusting a name. An old icon name still resolves (`renamedFrom` names it), unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word and `oldNameNotes` names the icon the old name installs. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale. With no `topic` it returns the core workflow, the topic index and the installer; `topic` (for example `motion`) returns that one file in full. Compare its `version` with the one stamped atop your local `SKILL.md`.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Commands (MCP prompts)
In Claude Code the ReUI MCP adds these prompts; other agents show MCP prompts under their own names, or not at all. Plain language runs the same loop.
- `/mcp__reui__build <what>`: compose a page, section or feature (plan, install, read the API, adapt, audit).
- `/mcp__reui__add <item>`: find, install and wire in 1 component, example, block or icon.
- `/mcp__reui__fix [target]`: fix ReUI usage (undocumented props, a base or radix mismatch, missing states, accessibility, scroll).
- `/mcp__reui__improve [target]`: refine existing ReUI UI (hierarchy, density, states, responsive, motion).
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- Icon results and `get_icon` include `animated: true` and `installAnimated` only when the icon has a real hover animation (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`). Use the strings given; never build an animated path by hand, it installs a static copy for every other icon.
- Blocks, `c-*` examples and icons carry `thumbnail`, an absolute URL to a hosted preview image (light theme; blocks 1080x720, examples 900x600, icons 640x200 with the 4 styles side by side). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Guidance is getter-only, never on search rows: `get_component` adds `whenToUse`, `whenNotToUse` and `agentNotes`; `get_example` and `get_block` add `bestFor`, `agentNotes` and `whenNotToUse`, and `get_block` also `categoryGuidance`; `get_examples` rows add `bestFor`. Answers carry the key lines; the item page's `.md` twin has every line. Each `whenNotToUse` entry is `{ when, instead, type }`: fetch `instead` with the getter its `type` names.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+83
View File
@@ -0,0 +1,83 @@
---
name: reui
description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind. Use for any UI work in a project with @reui in components.json or a components/reui folder, and in any shadcn project that asks for data grids, kanban boards, dashboards or full pages: building or restyling pages, forms and tables; colors, theme tokens and dark mode; spacing, typography and layout; animation and hover motion; installing ReUI components, examples, Pro blocks or Motion Icons. Works with the ReUI MCP."
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
---
> **ReUI skill version `68f0e6fa91`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer in the mode it was installed with to update this skill: `get_agent_skill` -> `install.recommended` from the project root, with `REUI_GLOBAL=1` before `node` when this file is `~/.claude/skills/reui/SKILL.md` or `~/.agents/skills/reui/SKILL.md` (a global install). Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI
ReUI is an existing design system on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here.
It ships free components, free `c-*` examples, Pro blocks and Ultimate Motion Icons through the shadcn CLI; the ReUI MCP finds them and serves their APIs.
## Order of authority
1. The installed style (`components.json` `style`, baked into `components/ui` and `components/reui`): it owns radius, height, padding, shadow and overlay motion.
2. The installed component's variants and props (`size`, `variant`, `spacing`).
3. These rules and the topic files.
4. Other design skills, then your taste: where they disagree with 1 to 3, ReUI wins. Never redefine `--ease-out` or any Tailwind built-in.
## Before you write UI code, write this plan in your reply
1. Surface: operational, marketing or flow.
2. Archetype: 1 from [compose.md](./rules/compose.md).
3. Container family: Frame-led, Card-led, or Cards for the KPIs and a Frame for the table.
4. Density: dense (`size="sm"` on controls that take it) or default.
5. Regions: top to bottom, each with the item that fills it.
6. States: empty, loading and error for each data region.
7. Motion: none, or the 1 entrance.
Then build exactly that plan; if you deviate, say where.
## The loop
1. **Find**: `compose_page` for a full page, else `search` with `type` and `component` hints.
2. **Install**: `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a tool returned it (shadcn/ui parts install by bare name: `npx shadcn@latest add button --yes`).
3. **Read the API on your base**: `components.json` style `base-nova` is Base UI, `radix-nova` is Radix. Call `get_component([...componentsUsed])` once, then install 1 `get_examples` example and read its files.
4. **Adapt**: change data, copy and props; keep the structure and classes.
5. **Check**, after every UI edit, follow-ups included: `validate_usage` on any name you did not read, the self-checks C1 to C4 in [quality.md](./rules/quality.md), [anti-patterns.md](./rules/anti-patterns.md), `get_audit_checklist`, typecheck, lint. Hand back in the 5 lines quality.md gives.
Show every item's `previewUrl` whenever you list, recommend or install it.
## Rules that always apply
- **Owners**: tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` (destructive confirms `AlertDialog`), toasts `sonner`; never hand-roll.
- **Color**: semantic tokens for meaning; state colors through Badge or Alert variants (`@reui/badge` and `@reui/alert`, not shadcn's); raw palette only for categorical identity, always with its `dark:` pair.
- **Contrast**: never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
- **Shape**: no `rounded-*` except `rounded-full`, and no `shadow-*`, `h-*` or `px-*` on Button, Input, SelectTrigger, Card or Frame; pick `size` and `variant`. A panel is a Card or Frame, never a div with `rounded-lg border`.
- **Spacing**: `gap-*` on the parent, not `space-y-*`; `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections; title to description `gap-0.5`.
- **Type**: `text-sm` body, `text-xs` meta (controls keep their style's size), `font-medium` or `font-semibold`, headings `tracking-tight`, numbers `tabular-nums`; nothing below `text-xs` except illustrative mini-UI.
- **Motion**: installed components own their motion. Your own code uses `transition-colors` for hover and press, `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` to reveal, `duration-300` with the same curve at most (media hover zoom: 500), and `SPRING` (`{ type: "spring", visualDuration: 0.25, bounce: 0 }`) for drag and reorder. At most 1 entrance animation per view (a stagger counts as 1). Animate only `transform` and `opacity`, CSS before motion/react, never set state per frame; `<MotionConfig reducedMotion="user">` once at the root plus `motion-reduce:` on CSS, where reduced motion means a calm fade, not nothing.
- **Surfaces**: prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card. Icons come from 1 library, the `iconLibrary` in `components.json` (on Ultimate, 1 Motion Icon on the primary action is the exception).
- **States**: each data region has empty (1 action), loading (a `Skeleton` in the real layout) and error (Retry) states; light and dark work.
- **Never invent**: never write a prop, variant or `@reui/` name you did not read in an `api`, an installed file or a tool result.
## Topics
Always read `compose` before building a screen, and `quality` plus `anti-patterns` at the Check step. Besides those, read only the topic files the task touches, at most 4 (for example `color`, `motion`, `layout` and `components`).
Installed skill: open the file. MCP only: call `get_agent_skill` with the topic, or fetch `https://mcp.reui.io/skill/reui/<file>`.
| Topic | File | Read when |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| `compose` | [rules/compose.md](./rules/compose.md) | before building any screen or section |
| `workflow` | [rules/workflow.md](./rules/workflow.md) | `compose_page`, search hints, installing |
| `adapting` | [rules/adapting.md](./rules/adapting.md) | changing an installed block or example |
| `components` | [rules/components.md](./rules/components.md) | `data-grid`, `filters`, `kanban` and the rest |
| `color` | [rules/color.md](./rules/color.md) | any color, status, chart or dark mode choice |
| `theming` | [rules/theming.md](./rules/theming.md) | the styles, radius, density and sizes |
| `motion` | [rules/motion.md](./rules/motion.md) | any transition, animation or motion/react |
| `layout` | [rules/layout.md](./rules/layout.md) | house defaults, hierarchy, surfaces, states |
| `icons` | [rules/icons.md](./rules/icons.md) | icon imports, Motion Icons |
| `quality` | [rules/quality.md](./rules/quality.md) | the done gate: self-checks, a11y, hand-back |
| `anti-patterns` | [rules/anti-patterns.md](./rules/anti-patterns.md) | the Check step: drift pairs, stale habits |
| `registry` | [rules/registry.md](./rules/registry.md) | free vs Pro vs Ultimate, base vs radix |
| `cli` | [rules/cli.md](./rules/cli.md) | `components.json`, license key, install flags |
| `tools` | [tools.md](./tools.md) | MCP result shapes, errors, quota, commands |
## Plans
Free covers components, `c-*` examples, this skill and the MCP (a ReUI account and a daily allowance). Pro adds blocks. Ultimate adds Motion Icons. A plan miss is a normal answer with `locked: true`; keep building with what the plan covers.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "ReUI"
short_description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind."
default_prompt: "Use ReUI to build this UI with installed components and the ReUI motion and color rules."
+40
View File
@@ -0,0 +1,40 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- Never read `window`, `localStorage`, `location`, `Date.now()` or `Math.random()` in render or in a `useState` initializer; read them in an effect.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something, and the layout must still hold with long names, empty values and crowded data.
+117
View File
@@ -0,0 +1,117 @@
# Anti-patterns
Read this at the Check step, after the self-checks in [quality.md](./quality.md). Each entry: an `Incorrect:` line, then its `Correct:` fix. Detector: the self-check that prints it (C1 to C4), or `visual` when reading finds it. Source: the pilot run that wrote it, or `default` (a training-data habit).
## Pairs
### 1. Stagger on every row (Detector: C2; source: run-08)
Incorrect: `transition={{ delay: i * 0.05 }}` on all 50 rows of a table
Correct: 1 stagger of at most 6 items, `staggerChildren: 0.05`, never on table rows
### 2. Transition everything on a card (Detector: C1; source: runs 10, 15)
Incorrect: `className="rounded-xl border transition-all duration-300 hover:shadow-lg"`
Correct: `transition-colors` for a hover color; a lift is `transform` over a static shadow
### 3. Raw palette for a state (Detector: C3; source: runs 08, 15)
Incorrect: `<span className="text-green-600">Active</span>`
Correct: `<Badge variant="success-light">Active</Badge>`
### 4. Animated table rows (Detector: C1; source: runs 01, 08, 17)
Incorrect: `<motion.tr whileHover={{ backgroundColor: "var(--muted)" }}>`
Correct: `data-grid` rows, which already hover; never animate rows with motion/react
### 5. Hover scale on the default spring (Detector: C1; source: runs 01, 10)
Incorrect: `<motion.div whileHover={{ scale: 1.03 }}>` with no `transition`
Correct: CSS first: `transition-colors` for hover, `transition-transform active:scale-[0.98] motion-reduce:transition-none` for press
### 6. Entrance from scale 0 (Detector: C1; source: runs 10, 15)
Incorrect: `<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>`
Correct: `initial={{ opacity: 0, scale: 0.95 }}`: start at scale 0.95 or more and move 8px or less
### 7. Hand-built modal or toast (Detector: C4; source: run-15)
Incorrect: `<div className="fixed inset-0 z-50 bg-black/50">` as a modal
Correct: `Dialog`, `AlertDialog` for a destructive confirm, `sonner` for toasts; a palette: `<CommandDialog><Command><CommandInput /><CommandList>`, as `CommandDialog` holds no `Command`
### 8. Native select (Detector: C4; source: run-15)
Incorrect: `<select>` with `<option>` children
Correct: the shadcn `Select`, or `NativeSelect` when the native control is wanted
### 9. Hand-rolled table (Detector: C4; source: run-17)
Incorrect: `<table>` with mapped `<tr>` rows
Correct: `data-grid`: a `useTable({ features: dataGridFeatures })` instance passed as `table={table}`, columns typed `ColumnDef<DataGridFeatures, Order>[]`
### 10. Button inside a clickable wrapper (Detector: visual; source: run-02)
Incorrect: `<motion.button><Button>Save</Button></motion.button>`
Correct: the `Button` alone: 1 interactive element, and it already has a press state
### 11. Radix asChild on Base UI (Detector: C4; source: run-17, judge)
Incorrect: `<DialogTrigger asChild><Button>Open</Button></DialogTrigger>`
Correct: `<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>` on base-nova; a link: `<Button nativeButton={false} render={<Link href="/x" />}>`
### 12. Component defined inside render (Detector: visual; source: Haiku runs, judge)
Incorrect: `const Row = () => <div />` inside the page component
Correct: declare `Row` at module scope and pass it props, so React keeps its state
### 13. A panel inside a panel (Detector: visual; source: run-05)
Incorrect: `<FramePanel><Card>...</Card></FramePanel>`, or a Card inside a Card
Correct: FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame
### 14. Small badges (Detector: visual; source: runs 03, 04, 06, 07, 10, 13, 16, 18)
Incorrect: `<Badge variant="success-light" size="sm">Paid</Badge>`
Correct: `<Badge variant="success-light">Paid</Badge>` with `size` left off
### 15. A colored circle behind every KPI icon (Detector: visual; source: default)
Incorrect: `<div className="rounded-full bg-blue-100 p-2">` around each KPI icon
Correct: the number leads; at most 1 `text-muted-foreground` icon
### 16. Gradient text (Detector: visual; source: default)
Incorrect: `bg-linear-to-r from-violet-500 to-pink-500 bg-clip-text text-transparent` on a heading
Correct: `text-foreground` and `tracking-tight`; color carries meaning, not decoration
### 17. A dot or check for the current item (Detector: visual; source: default)
Incorrect: a dot or a `Check` icon beside the active nav item
Correct: `bg-muted` (`dark:bg-muted/50`) plus `aria-current`
### 18. framer-motion (Detector: C4; source: default)
Incorrect: `import { motion } from "framer-motion"`
Correct: `import { motion } from "motion/react"`
### 19. Clock or randomness in render (Detector: C4; source: default, near run-05)
Incorrect: `const [id] = useState(() => Math.random())`
Correct: `useId()` for ids; read the clock in an effect or an event handler
### 20. Placeholder data (Detector: visual; source: default)
Incorrect: "John Doe", "Acme Inc" and round figures like `$1,000`
Correct: specific, plausible data: "Mara Lind", `$1,284.50`, 37 open orders
## Your training data is out of date
| Old habit | In this stack |
| ----------------------------- | --------------------------------------------------------------- |
| `framer-motion` | `motion/react` |
| `<DataGrid data columns>` | `useTable({ features: dataGridFeatures })` plus `table={table}` |
| A fixed div modal | `Dialog` or `AlertDialog` |
| A hand-built toast | `sonner` |
| `w-5 h-5` | `size-5` |
| never `space-y-4` in new code | `flex flex-col gap-4` |
| `asChild` | `render` on base-nova |
+77
View File
@@ -0,0 +1,77 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the ReUI components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks and Motion Icons) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
`<name>` is the item name exactly as a ReUI tool returned it. The `@reui/` prefix is for ReUI items only:
- The shadcn/ui components ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. Install them from shadcn by bare name: `npx shadcn@latest add button --yes`.
- `badge` and `alert` exist in both registries: `@reui/badge` and `@reui/alert` are ReUI's components, bare `badge` and `alert` are shadcn's.
- In an item's `registryDependencies`, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both, so you never add them by hand.
- Templates are not registry items. Download a template from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs one.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the ReUI components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) need Ultimate.
- Templates are downloads from their page on reui.io, not installs: free ones need only a ReUI account, and each template's page says what the others need.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
If an install 404s, the name is not a ReUI item. Read the CLI's error message, which names the right install command when there is one, or look the name up with `get_install_command` or `search`.
+74
View File
@@ -0,0 +1,74 @@
# Color
Semantic tokens carry meaning; raw palette carries identity. Check every color in light and dark.
## Tokens
Surfaces, text and borders use your theme tokens (`background`, `foreground`, `card`, `muted`, `muted-foreground`, `primary`, `accent`, `destructive`, `border`, `ring`), so a theme change re-tints them. ReUI adds state tokens: `--success`, `--info` and `--warning` with their `-foreground`, `--destructive-foreground` (beside shadcn's `--destructive`) and `--invert` with `--invert-foreground` for inverted surfaces.
They are not part of shadcn's base theme. Installing `@reui/badge`, `@reui/alert` or `@reui/code-block` adds them to your global CSS; until one of those is installed, classes such as `bg-success` generate nothing. Check for `--success:` in your global CSS. If it is missing, add these values to `:root` and `.dark`, plus a `--color-<name>: var(--<name>)` line for each under `@theme inline`:
<!-- gen:tokens:start -->
| Token | `:root` | `.dark` |
| -------------------------- | -------------------------- | -------------------------- |
| `--destructive-foreground` | `var(--color-red-800)` | `var(--color-red-600)` |
| `--success` | `var(--color-emerald-500)` | `var(--color-emerald-500)` |
| `--success-foreground` | `var(--color-emerald-900)` | `var(--color-emerald-600)` |
| `--info` | `var(--color-violet-500)` | `var(--color-violet-500)` |
| `--info-foreground` | `var(--color-violet-900)` | `var(--color-violet-600)` |
| `--warning` | `var(--color-yellow-500)` | `var(--color-yellow-500)` |
| `--warning-foreground` | `var(--color-yellow-900)` | `var(--color-yellow-600)` |
| `--invert` | `var(--color-zinc-900)` | `var(--color-zinc-700)` |
| `--invert-foreground` | `var(--color-zinc-50)` | `var(--color-zinc-50)` |
<!-- gen:tokens:end -->
## Meaning vs identity
- Meaning (a state, emphasis, a surface, text) takes semantic tokens, through the Badge or Alert variant that already carries them.
- Identity (a chart series, a tag color, a team, an avatar) may use raw palette: ReUI's own lint keeps it on purpose. Always hand-pair it for dark, as in `text-violet-600 dark:text-violet-400`.
- **Incorrect:** `<span className="text-green-600">Active</span>` (raw palette for a state). **Correct:** `<Badge variant="success-light">Active</Badge>`.
## State recipes
- State badge: `<Badge variant="success-light">` from `@/components/reui/badge` (`@reui/badge`; shadcn's `components/ui/badge` has no `-light` variants or `size`), also `info-light`, `warning-light`, `destructive-light`, and `primary-light` for brand or pending. Neutral badge: `variant="outline"`. A solid `<Badge variant="success">` (white text) only when the user asks for a solid fill.
- Inline status text: `text-success-foreground dark:text-success`, the pair the ReUI Badge uses.
- Tinted surface: `border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success` (the Badge's `success-light` recipe).
- A message block: `<Alert variant="success">` from `@/components/reui/alert` (`@reui/alert`), also `info`, `warning` and `destructive`.
- Never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
## Contrast
<!-- gen:contrast:start -->
Light is `text-<tone>-foreground` on `background` and on the `bg-<tone>/10` tint; dark is `dark:text-<tone>` on `background` and on the `dark:bg-<tone>/15` tint. Ratios against the neutral theme, rounded down; body text needs 4.5:1.
| Tone | Light | Light on tint | Dark | Dark on tint |
| ------------- | ----- | ------------- | ---- | ------------ |
| `success` | 9.6 | 8.8 | 8.0 | 6.6 |
| `warning` | 8.6 | 8.1 | 10.3 | 8.1 |
| `info` | 11.0 | 9.7 | 4.4 | 3.9 |
| `destructive` | 8.3 | 7.0 | 6.8 | 5.7 |
Raw state color as light-mode text on `background`: `text-success` 2.4, `text-warning` 1.9, `text-info` 4.4.
<!-- gen:contrast:end -->
Info text in dark mode is under 4.5:1: where it must be read, write the words in `text-foreground` and color only the icon.
## Dark mode
- shadcn uses the class strategy: `@custom-variant dark (&:is(.dark *));` in your global CSS, and `.dark` on `<html>` turns it on.
- Tokens switch by themselves; raw palette needs its `dark:` pair.
- Check both themes before you hand back.
## Charts
Check your theme's `--chart-1` to `--chart-5` in both modes. The current shadcn themes keep the same chart colors in light and dark; some themes (reui.io's own among them) change hue. For a series whose identity must hold in both modes, use a palette var such as `var(--color-violet-500)`, as ReUI blocks do.
<!-- gen:charts:start -->
Same chart colors in light and dark: neutral, stone, zinc, mauve, olive, mist, taupe, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow. Changes hue between light and dark: reui.io's own theme.
<!-- gen:charts:end -->
+521
View File
@@ -0,0 +1,521 @@
# ReUI components
The ReUI components: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `color-picker`, `data-grid`, `date-selector`, `emoji-picker`, `event-calendar`, `file-type`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `signature-pad`, `sortable`, `stepper`, `time-picker`, `timeline`, `tree`. Examples and blocks are composed from these. `file-upload` is a ReUI component too, shipped as the `use-file-upload` hook rather than a primitive, so it is not in that list: install it as `@reui/use-file-upload`, which writes `hooks/use-file-upload.ts` (import `useFileUpload` from `@/hooks/use-file-upload`), call `get_component("file-upload")` for its API and `get_examples("file-upload")` for its free examples. There is no `@reui/file-upload`: the UI is a `c-file-upload-<n>` example, and one built on the hook installs it.
**Rule one: never guess a component's API.** Call **`get_component(name)`** for its inline `api` and share its `docsUrl` with the user, then **`get_examples(name)`** and copy a worked example. The contracts below are first-try orientation (required props, shape, the one gotcha); the `api` is the reference. A large `api` (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries: `sectionsOmitted` names what was dropped and the response's `next` hint pulls it back. `validate_usage` always checks the full API. No single block fits? Compose from components, 1 `get_examples` example each ([compose.md](./compose.md)).
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table with `useTable({ features: dataGridFeatures, ... })`. `dataGridFeatures` already bundles sorting, filtering, pagination, selection, expanding, pinning, resizing and faceting: no row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data: `manualPagination: true` and `rowCount: total` in `useTable`, `recordCount={total}`, and fetch with the table's `pagination` state (`DataGridPagination` changes `pageSize`).
- Columns are TanStack v9, `ColumnDef<DataGridFeatures, Order>[]`, never v8's `ColumnDef<Order>[]`; a sortable column's `header` renders `DataGridColumnHeader` (a string header cannot sort).
- Imports, each from `@/components/reui/data-grid/<file>`: `DataGrid`, `DataGridFeatures` and `dataGridFeatures` from `data-grid`, `DataGridColumnHeader` from `data-grid-column-header`, `DataGridTable` from `data-grid-table`, `DataGridPagination` from `data-grid-pagination`, `DataGridScrollArea` from `data-grid-scroll-area`; `useTable` and `ColumnDef` from `@tanstack/react-table`.
- States: `isLoading` renders skeleton rows from each column's `meta.skeleton` (a `ReactNode`: `<Skeleton className="h-4 w-20" />`); `emptyMessage` (a `ReactNode`) fills a grid with no rows: after a failed fetch, pass it the error with a Retry.
- Numeric column: `meta: { headerClassName: "text-end [&>div]:justify-end", cellClassName: "text-end tabular-nums" }`.
- Rows edited in place: `autoResetPageIndex: false` in `useTable`, or each write jumps back to page 1.
- In a Frame: `<FramePanel className="p-0">` around `DataGridScrollArea` and `DataGridTable`, plus `tableClassNames={{ base: "min-w-[720px]" }}` so on a phone the table scrolls inside the panel instead of squeezing.
```tsx
const columns = useMemo<ColumnDef<DataGridFeatures, Order>[]>(() => [
{ accessorKey: "total", header: ({ column }) => <DataGridColumnHeader title="Total" column={column} /> },
], [])
const table = useTable({ features: dataGridFeatures, data, columns })
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Never a raw `<table>` or hand-rolled pagination: the API covers sticky header, virtualization and row selection.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<div className="flex items-center justify-between">
<h3>{id}</h3>
<KanbanColumnHandle aria-label="Move column"><GripVertical /></KanbanColumnHandle>
</div>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks. `KanbanColumnHandle` is hidden until its column is hovered or it has keyboard focus, so keep the column title outside it.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()`: ids come from `createFilterIdFactory(seed)` seeded off `useId` (never minted in render), and `createFilterRule({ id })` takes one, `createFilterQuery()` none. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string, and `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches the current level and everything under it, `searchScope="global"` the whole tree from any level, and both annotate results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `virtualize` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels (paging, retry, `prefetch`). `CascaderFooter` pins commands below the list and `CascaderSubmenu` opens one as a side flyout. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/reui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window). `smooth` defaults to true: pass `smooth={!reduceMotion}` from motion/react's `useReducedMotion()`. Mark the active link `aria-current="location"` (track it with `onUpdate`); `"page"` is for route links.
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<FramePanel>Content</FramePanel>
<FrameFooter>Footer</FrameFooter>
</Frame>
```
**Gotcha:** the shell for data and tool-like surfaces. `spacing` (`xs` | `sm` | `default` | `lg`) sets panel padding and the gap between panels, so pick it instead of padding classes (the 1 exception: `<FramePanel className="p-0">` around a `data-grid`); `variant` is `default` | `inverse` | `ghost`; `stacked` joins panels with shared borders; `dense` removes the frame's own inset and gap (panels sit flush), and panel padding stays; `--frame-radius` is the one radius knob (panels derive `--frame-panel-radius`).
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light">Success</Badge>
<Badge variant="outline">Draft</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl` (keep the default), `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes: `<tone>-light` for a state, `outline` for a neutral label; an omitted `variant` is the solid primary fill.
## color-picker
**Required:** none (`<ColorPicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<ColorPicker value={color} onValueChange={setColor} alpha>
<ColorPickerTrigger />
<ColorPickerContent>
<ColorPickerPanel>
<ColorPickerArea />
<ColorPickerSlider channel="hue" />
<ColorPickerSlider channel="alpha" />
<ColorPickerFields />
</ColorPickerPanel>
</ColorPickerContent>
</ColorPicker>
```
**Gotcha:** the value is a CSS color string in `format` (`"hex"` default, `"rgb"`, `"hsl"`, `"hsb"` or `"oklch"`), `null` when empty, never an object. `onValueChange` fires on every drag move, so save in `onValueCommit`. Without `alpha` the value is always opaque. A bare `ColorPickerPanel` inside the root is an inline picker; `ColorPickerInput` replaces the trigger with a typable field.
## emoji-picker
**Required:** none; wire `onEmojiSelect` to insert a pick, or `value` + `onValueChange` to use it as a field.
**Shape:**
```tsx
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
<EmojiPickerTrigger />
<EmojiPickerContent>
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
```
**Gotcha:** the emoji data is fetched at runtime from a pinned jsDelivr path (`EMOJI_PICKER_DATA_URL`): under a strict Content Security Policy allow it in `connect-src`, self-host with `dataUrl`, or pass `data` (from `normalizeEmojibaseData`). Store the pick's `value` (the native emoji in its skin tone, or `:shortcode:` for a custom one). `multiple` makes the value a list, capped by `max`.
## file-type
**Required:** none; pass `type` (an extension, a family name or any string).
**Shape:**
```tsx
<FileType type="pdf" />
<FileType type={getFileType(file.name)} size="sm" aria-hidden="true" />
```
**Gotcha:** one `span` with `role="img"`, no children, no `"use client"` (fine in a Server Component). Resolve a file name, path, URL or MIME type with `getFileType`, never by splitting on the dot. `variant`: `outline` (default) | `solid` | `light` | `mono`; `size`: `mini` | `xs` | `sm` | `default` | `lg` | `xl` (18 to 56px); recolor with a `text-*` class. Pass `aria-hidden="true"` when the file name sits beside it.
## signature-pad
**Required:** `SignaturePad` > `SignaturePadArea`
**Shape:**
```tsx
<SignaturePad value={strokes} onValueChange={setStrokes}>
<SignaturePadArea>
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
```
**Gotcha:** the value is an array of strokes (`{ points: [x, y, size][], color? }`), not an image: store it as JSON, and export with `useSignaturePad()` or `apiRef` (`toSVG`, `toDataURL`, `toBlob`) or `SignaturePadSave` (`format`, `onSave`). `onValueChange` fires per finished stroke, never per pointer move. Size the area with a class (`h-40` by default); an overlay in it that is not a control needs `pointer-events-none`. Drawing needs a pointer, so offer a typed name where a signature is required.
## time-picker
**Required:** none (`<TimePicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<TimePicker value={time} onValueChange={setTime}>
<TimePickerTrigger />
<TimePickerContent>
<TimePickerColumns />
<TimePickerFooter>
<TimePickerNow />
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
```
**Gotcha:** the value is a 24-hour `"HH:mm"` string (`"HH:mm:ss"` with `granularity="second"`) or `null`, never a `Date`, whatever `hourCycle={12}` shows. `minuteStep`, `min`, `max` and `isTimeDisabled` narrow the options; keep `isTimeDisabled` stable (module scope or memoized). `requireConfirm` holds picks as a draft until OK. `TimePickerInput` replaces `TimePickerTrigger` for a typable field, never beside it.
## base vs radix - write for the project's base
Every component ships a `base` (Base UI) and a `radix` (Radix UI) build under 1 name, and the CLI installs the one the project uses. Read `components.json` `style`: the part before the first `-` is the base (`base-nova` is **Base UI**, `radix-nova` is **Radix UI**). Write against that base; the deltas mirror shadcn's:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
Installed files and `c-*` examples are already in your base: reuse their wiring. When an `api` or example shows the other base's shape, translate it (or `validate_usage` to confirm).
+62
View File
@@ -0,0 +1,62 @@
# Compose a screen
Read this before you build any screen or section, then write the router's 7-line plan in your reply (Surface, Archetype, Container family, Density, Regions, States, Motion) and build exactly that plan. Style it with the topics the screen touches: [layout.md](./layout.md) (house defaults), [color.md](./color.md), [motion.md](./motion.md).
If the brief names a layout or a motion (a left nav, 2 entrances), follow the brief and name the default you overrode. Several entrances run as 1 stagger: at most 6 items, 50ms apart, never on table rows or lists of 20 or more.
## Surface
- **Operational**: a screen people work in every day (dashboard, records, settings, admin tools). Frame-led, dense (`size="sm"` on controls that take it), no hero, no page-load motion.
- **Marketing**: a page a visitor reads once (landing, pricing, features). Card-led sections on the spine below, default density.
- **Flow**: a task people finish and leave (sign in, onboarding, checkout). 1 centered Card or a Dialog, 1 primary action per step.
## Archetypes: pick 1 per screen
- **Vertical rail settings** (3 to 6 sections): a section rail on the left, 1 content column, 1 section at a time; the rail turns horizontal below `md`.
- **Horizontal tabs** (5 to 8 views of 1 object): a full-width `Tabs` rail under the header that scrolls sideways when it overflows, 1 content column.
- **Frame stack**: stacked Frames, each 1 focused group of settings or records; a flat page split by `Separator` rows when it should feel lighter.
- **Table-backed management**: header, toolbar (search, `filters`), then a `data-grid` with row actions and pagination, all in 1 Frame.
- **Dashboard grid**: a KPI row, then 1 or 2 chart or list panels, then the table.
- **Board**: header and filters, then a `kanban` board; `KanbanBoard` stacks its columns below `sm`, and a wider board scrolls inside its own region.
- **Record detail**: header (title, status `Badge`, actions), a main column of sections, a side column of facts that stacks under it below `lg`.
- **Marketing spine**: optional `Badge` eyebrow, headline (the only loud element), 1-clause subhead, action row (1 primary `Button`, a second `variant="outline"`, a third a text link), specific proof beside it ("27 reviews"), media. Omit parts; never reorder them.
- **Auth card**: 1 centered `Card` at `max-w-sm`: title, fields, 1 full-width primary `Button`, a text link to the other flow.
- **Wizard dialog**: a `Dialog` holding a `stepper`, 1 step visible, Back (`variant="outline"`) and Next (primary) in its footer.
An app shell around any of them is the shadcn `Sidebar`, which turns into a `Sheet` below `md` by itself.
## Fill each region in this order
1. The ReUI component that owns the job: the router's Owners (`data-grid`, `filters`, `kanban`), `useFileUpload` from `@/hooks/use-file-upload` for file drop, or the one whose contract in [components.md](./components.md) matches.
2. A shadcn/ui component (`Tabs`, `Dialog`, `AlertDialog`, `Sheet`, `Empty`, `Skeleton`, `sonner`).
3. A free `c-*` example from `get_examples` that already composes it: install it and adapt.
4. Your own composition of those parts. Never a hand-rolled table, modal or tabs.
Import ReUI parts file by file: `data-grid`, `filters`, `cascader`, `event-calendar` and `gantt` are folders with no index file. `data-grid`: the import line in [components.md](./components.md). `filters`, each from `@/components/reui/filters/<file>`: `Filters` from `filters`, `createFilterQuery` and `flattenFilterConditions` (1 argument, the query) from `filters-query`, the types `FilterField` and `FilterQuery` from `filters-types`.
## Wiring that works
- `data-grid`: columns are `ColumnDef<DataGridFeatures, Order>[]`, a sortable header renders `DataGridColumnHeader`, and server paging needs `manualPagination: true` and `rowCount`.
- `filters`: apply each condition by its `operator`. Negate swaps a rule to its inverse (`is` and `is_not`, `contains` and `not_contains`, `is_any_of` and `is_none_of`, `empty` and `not_empty`) and sets `negated` only when there is none: honour both.
- A Base UI `Button` that navigates: `<Button nativeButton={false} render={<Link href="/x" />}>`.
- shadcn's `CommandDialog` has no `Command` inside, unlike older shadcn: wrap `CommandInput` and `CommandList` in one. `CommandItem` search matches its `value`: omit it (text is used) or use the label; ids in `onSelect`.
## Headers
A page or panel header puts the title (1 to 3 words) and a 1-clause description on the left and the actions on the right: 1 primary `Button`, the rest `variant="outline"`, row actions `variant="ghost"`.
## Responsive
- No horizontal page scroll at 390px: grids drop to 1 column, side columns stack, `min-w-0` on every shrinking parent.
- A KPI row is `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4`.
- Every header and toolbar action stays reachable at every width: a control hidden below a breakpoint moves into a menu shown only there.
- A table scrolls sideways inside its Frame, never the page (the `data-grid` contract in [components.md](./components.md)).
## States and motion
- Each data region owns its states: empty (shadcn `Empty` with a title, 1 line and the primary action), loading (`Skeleton` in the real layout; `data-grid` takes `isLoading`) and error (inline in the region, with Retry). Never 1 spinner for the whole page.
- Motion: none, or the 1 entrance per view; installed parts keep their own motion, and hover and press follow [motion.md](./motion.md). An Alert, field error, notice or data that appears after an action or a load is a reveal, not an entrance: fade it in with the reveal row.
## Remove first
Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments. Add a region only when the brief needs it.
+43
View File
@@ -0,0 +1,43 @@
# Icons (ReUI delta over shadcn)
Use the project's configured `iconLibrary`, put `data-icon="inline-start"` on an icon before the label inside `Button` (`data-icon="inline-end"` on one after it), put no sizing classes on icons inside components, and pass icons as components, not string keys. ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. The shadcn CLI installs each item's icons in the library named by `iconLibrary` in `components.json` - you swap nothing. When `iconLibrary` is not set, the CLI falls back to `lucide`, so the icons arrive as `lucide-react` imports. If that is not the project's library, set `iconLibrary` before installing, or change the **import source and component name** to your library, keeping the same icon-name semantics (each key below is an `iconLibrary` value):
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remixicon` -> `@remixicon/react`
- `hugeicons` -> icons from `@hugeicons/core-free-icons`, rendered through `HugeiconsIcon` from `@hugeicons/react`
Don't assume `lucide-react`: read `iconLibrary` from `components.json`, and when it is unset, check which icon package the project already uses.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled). Every icon installs from the default path; some also have a real hover animation (motion/react) on the animated path:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static, every icon
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated, only when the icon has installAnimated
```
The animated path answers for every icon, but for an icon without a real animation it installs the same static drawing, so only use it when the icon carries `installAnimated`.
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. On Ultimate, a Motion Icon on the primary action is the 1 hover flourish (see [motion.md](./motion.md)).
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a real hover animation; a concept with none comes back empty and `animatedNote` says so.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` only when the icon really animates - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (the icon's own page) and a `thumbnail` (its 4 styles side by side) - **show or share them** so the user can SEE the icon before installing, and look at the thumbnail yourself before trusting a name.
- Several icons can fit one concept. Before installing, read `get_icon`'s `whenNotToUse` (each entry names the icon to use instead), and use its `label` as the `aria-label` of an icon-only button.
- Icon names were revised in the icons v2 update. An old name (`save-2`, `abstract-18`) still answers in `get_icon`, `get_install_command`, `validate_usage` and `get_thumbnail` with the icon it became and `renamedFrom`, and still installs that same drawing; write the current name in new code. That includes names renamed because they misdescribed the drawing (`watch` installs a sand timer, `chevrons-down` points up), so look at the `thumbnail` before trusting an old name. `search_icons` answers an old name the same way, unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word, and `oldNameNotes` names the icon the old name installs. `search` ranks by current names only, so look an old name up with `get_icon`, never `search`. Only `tag` (it drew a money bag) no longer answers: the reply names the icons to use instead.
The `icon-stack` component composes multiple icons into a stacked display.
+56
View File
@@ -0,0 +1,56 @@
# Layout: house defaults, hierarchy, spacing, surfaces and states
## Point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
**The bar:** before you finish, ask: would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content? If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
## House defaults
<!-- gen:house-defaults:start -->
- **Spacing ladder**: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections, `gap-0.5` title to description. Deviate when: skip a level if you like, never invert them.
- **Panel title**: no size class on `FrameTitle` or `CardTitle`. Deviate when: never; `FrameTitle` ships `text-sm font-semibold`, `CardTitle` follows the installed style.
- **Meta text**: `text-xs`. Deviate when: never smaller, except inside illustrative mini-UI artwork.
- **Frame spacing**: `spacing="default"`, written out. Deviate when: `sm` in a sheet, dialog, drawer or popover, or for a dense data grid in a panel; `xs` or `lg` only with a stated reason.
- **Leading row icon**: `size-4`. Deviate when: never in a glyph column; `Spinner` ships `size-4`, so a loading swap keeps the width.
- **Icon inside a control**: no size class; the control sizes it per style and size. Deviate when: never pin `size-*` on an svg inside `Button`, `InputGroupButton`, `InputGroupAddon`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `SelectItem`, `CommandItem`, `Toggle`, `ToggleGroupItem`, `IconTile` or `MarkerIcon`.
- **Badge size**: `default` (leave `size` off). Deviate when: `lg` or `xl` only when the user asks; never `sm` or `xs`.
- **Neutral badge**: `variant="outline"`. Deviate when: never `secondary`, `default` or `primary-light` for an id, tag, version, count or Draft/Beta label; inline after the title, never `ml-auto`.
- **State badge**: `<tone>-light`: `success-light`, `info-light`, `warning-light`, `destructive-light`; `primary-light` for brand or pending. Deviate when: never the solid tone, `secondary` or `outline` for a state; 1 state badge per row.
- **Solid badge**: never unless asked; always name a variant (an omitted one is the solid primary fill). Deviate when: only when the user asks for a solid fill (`default`, `secondary`, `success`, `info`, `warning`, `destructive`, `focus`, `invert`).
- **Slot padding**: the primitive's own inset; set density with `Frame spacing=` or `Card size="sm"`. Deviate when: never a padding class on `CardHeader`, `CardContent`, `CardFooter`, `FrameHeader`, `FramePanel`, `FrameFooter`, `SheetHeader`, `SheetContent`, `SheetFooter`, `DialogHeader`, `DialogContent`, `DialogFooter`, `PopoverContent`, `DropdownMenuContent`, `TabsContent` or `AccordionContent`.
- **Inline separator**: `<span aria-hidden className="bg-muted-foreground/40 size-1 shrink-0 rounded-full" />`. Deviate when: never a typed `·` between rendered segments; a literal dot only inside 1 data string.
- **List rows**: dense, auto height; a row with no second line stays 1 tight line. Deviate when: never uniform height.
- **Current list item**: ghost hover fill (`bg-muted`, `dark:bg-muted/50`) plus `aria-current`. Deviate when: never a dot or a check mark.
<!-- gen:house-defaults:end -->
## Hierarchy
- 1 focal point per card or panel: the dominant metric or task, then its label, then detail. Borders, separators and surfaces make 2 to 3 bands, never equal weight everywhere.
- Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments.
## Spacing and copy
- Siblings take the parent's `gap-*`, never their own margins; a heading sits nearer its own content than the section above it. Never pad an operational surface like a marketing page.
- Titles 1 to 3 words, a description 1 clause (about 8 words), a context line 2 segments at most, labels and buttons 1 to 2 words, a toast title 4 words at most. Copy that wraps at desktop width is over budget: cut it, never shrink the type.
## Surfaces
- `Frame` for data and operational surfaces (tables, lists, tool panels); `Card` for bounded sections and marketing.
- Prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card.
- A `data-grid` inside a Frame is the one place for `<FramePanel className="p-0">`; elsewhere no padding on slots. This is the 1 exception to the Slot padding row above.
- 1 archetype per screen (operational ones and the marketing spine): [compose.md](./compose.md) lists each with its regions.
## Type and buttons
- A KPI value is `text-2xl font-semibold tracking-tight tabular-nums`.
- `size="sm"` on dense surfaces, `variant="outline"` for secondary actions, `ghost` for row actions; 1 primary per region, 1 variant per toolbar.
- A toolbar separator is `<Separator orientation="vertical" className="my-auto h-4" />`; at a 390px viewport a toolbar stays on 1 row.
## States and responsive
- Empty: a short message plus the primary action. Loading: a `Skeleton` in the real layout, never a centered spinner. Error: inline with a retry.
- No horizontal page scroll at 390px: reflow to 1 column instead of shrinking; `min-w-0` on the shrinking parent and `truncate` on single-line labels.
+80
View File
@@ -0,0 +1,80 @@
# Motion
## Installed motion: never retime it
<!-- gen:installed-motion:start -->
| Installed part | Duration |
| ------------------ | ----------------- |
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
<!-- gen:installed-motion:end -->
Never add or change `duration-*`, `ease-*`, `animate-*` or `transition-*` on installed parts (`components/ui`, `components/reui`); Button already presses with `active:not-aria-[haspopup]:translate-y-px`.
## House motion: your own code
<!-- gen:house-motion:start -->
| Use | Tailwind |
| ------------------------- | ------------------------------------------------------- |
| Hover, press, color | `transition-colors` (150ms, no duration or ease class) |
| Reveal or expand | `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` |
| Large surface (ceiling) | `duration-300` with the same curve |
| An overlay you build | `duration-100` with the same curve |
| Media hover zoom | `duration-500` with the same curve, `scale-105` at most |
| Width tied to the sidebar | `duration-200 ease-linear` |
<!-- gen:house-motion:end -->
- Press on a custom surface (not Button): `transition-transform active:scale-[0.98] motion-reduce:transition-none`.
- Hover color plus press on 1 element: `transition-[color,background-color,border-color,scale]` (`transition-colors` and `transition-transform` override each other; `scale-*` sets `scale`).
- motion/react takes the same values in seconds: `{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }`; hover `{ duration: 0.15 }`.
- Drag, reorder and layout use `const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }`. Define `SPRING` only when something drags, reorders or toggles layout.
## Hard rules
- Animate `transform` and `opacity`; color only for hover and press. Never `transition-all`; width, height, top or left only in the sidebar row.
- Never `animate-bounce` or a spring with bounce above 0. `ease-linear` only for progress, spinners, loops and the sidebar; never Tailwind's `ease-in`, `ease-out` or `ease-in-out`.
- `motion/react` only: never framer-motion, GSAP or react-spring.
- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its `Skeleton`) is a reveal, not an entrance: fade it in with the reveal row.
- Never add named `duration-*` utilities or redefine `--ease-out`.
- Loading: a `Skeleton` (it already pulses) in the real layout; a spinner only inside a busy button.
- Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its `installAnimated` command; never retime it.
## Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare `transition` (it also animates box-shadow and filter). (`reui/no-paint-animation`; 240px: judgement (no check))
- A hover lift is `transform` over a static shadow, or a shadow layer whose `opacity` fades; never a transitioned `hover:shadow-*`. (`reui/no-paint-animation`)
- CSS first: hover, press, reveals and ambient loops are Tailwind transitions, `tw-animate-css` or `@keyframes`. motion/react only for exit on unmount, drag, springs that follow input, `layout` and scroll-linked values. (judgement (no check))
- In motion/react, `x`, `y`, `scale`, `rotate`, `pathLength` and every SVG target run on the main thread: never `repeat: Infinity` on them (loop with CSS `@keyframes`), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element without `repeatDelay`, `repeatType: "mirror"` or `onUpdate`. (`reui/no-main-thread-loop`)
- SVG geometry (a rect's `width`, a circle's `r`) does not reflow the page but runs in motion's JS loop: one-shot only. (`reui/no-main-thread-loop`)
- Never set React state per frame (requestAnimationFrame, `useAnimationFrame`, `onUpdate`, scroll or pointermove listeners): bind styles to `useMotionValue`, `useTransform` or a CSS variable on the element. (`reui/no-frame-setstate`)
- A JS-driven loop (motion `repeat`, requestAnimationFrame, canvas) pauses offscreen with `useInView` or IntersectionObserver; `content-visibility: auto` never stops script, and a setInterval loop also pauses on `visibilitychange`. (`reui/loop-offscreen`)
- No permanent `will-change`: no `will-change-*` class or `willChange` style, except during a drag, removed when it ends. (`reui/will-change`)
- Stagger 50ms or less per step (`staggerChildren: 0.05`), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals use `whileInView` with `viewport={{ once: true }}`. (`reui/stagger-budget`; item count and tail: judgement (no check))
- A height reveal (`grid-template-rows`) starts only on a click or key press, runs 200ms or less, 1 at a time; never `initial={{ height: 0 }}`. (`reui/no-layout-transition`)
- The motion `layout` prop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through `@reui/sortable` or `@reui/kanban`. (judgement (no check))
- AnimatePresence `mode="wait"` only with an exit of 150ms or less; `initial={false}` around content present at first render. (`reui/stagger-budget`)
- Never hide the hero or the largest heading behind a motion/react `initial` at opacity 0 (hidden until JavaScript runs). (judgement (no check))
- At most 3 ambient loops per view: CSS `@keyframes` on transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (a `content-visibility: auto` wrapper with `contain-intrinsic-size: auto <h>` where nothing paints outside it, or IntersectionObserver toggling `animation-play-state`). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
## Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (`reui/reduced-motion`)
- The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (`reui/reduced-motion`)
- In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (`reui/reduced-motion`; half the viewport: judgement (no check))
- `<MotionConfig reducedMotion="user">` once at the app root (its default ignores the OS) AND a `motion-reduce:` path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (`reui/reduced-motion`)
- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a `transform` string keep animating, so a loop or those keys branch on `useReducedMotion()` and rest on the final value. (`reui/reduced-motion`)
- `useReducedMotion()` is null on the server: never let it pick markup (JSX, className, `initial`) or reach `animate` under `initial={false}`; branch only animate, transition, `while*` or exit values. (`reui/reduced-motion-ssr`)
- No SMIL (`<animate>`, `<animateMotion>`, `<animateTransform>`); canvas and requestAnimationFrame loops draw 1 resting frame under `prefers-reduced-motion: reduce`. (`reui/no-smil`, `reui/reduced-motion`)
- Smooth scroll only under `motion-safe:scroll-smooth`. A focus ring appears at once: never `transition-all`, `transition-shadow` or a bare `transition` on it. (`reui/reduced-motion`, `reui/focus-transition`)
- Loaders and typing dots keep their opacity pulse; every decorative loop gets `motion-reduce:animate-none`, never an inline `style={{ animation }}` (it beats the class). (`reui/reduced-motion`)
- Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (`reui/pausable-autoplay`)
- Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))
+57
View File
@@ -0,0 +1,57 @@
# Quality gates (security, accessibility, scroll, self-checks)
This is the **done gate**: before you call any ReUI work finished, call `get_audit_checklist` and pass every item below (plus the bar and the states in [layout.md](./layout.md)). Then run the self-checks, typecheck and lint, compare with [anti-patterns.md](./anti-patterns.md), and hand back in the 5 lines below.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance. Hover on a display-only card: make the card a link to its detail, or give it no hover.
- `aria-current="page"` on the current route link, `"location"` on the current in-page section link.
- Destructive confirms use `AlertDialog`, not `Dialog`, and cannot be dismissed while pending.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship their keyboard behavior; never re-implement it.
- Announce async UI: loading and error messages use `role="status"` or `aria-live`.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
## Self-checks
After every UI edit, run these with ripgrep on `<files>`: each file you wrote or changed, by path, never `components/ui` or `components/reui`. A clean check prints nothing; a hit prints `file:line:code`. The last filter drops comment lines.
```sh
# C1 banned motion: transition-all, bare or paint transitions, layout keys, bounce, overshoot, scale 0, hover and tap gestures, will-change
rg -nH 'transition-all|transition(-property)?: *[^;]*\b(all|width|height|top|left|margin|padding|box-shadow|filter)\b|[\x27\x60" ]transition( +(duration|ease|delay|hover|focus|active|group|motion)|[\x27\x60"])|transition-shadow|transition-\[[^\]]*\b(width|height|top|left|right|bottom|inset|margin|padding|filter|radius|background-position|background-size|all)\b|animate-bounce|will-change|willChange|bounce: *(0?\.0*[1-9]|[1-9])|[\x27"](backIn|backOut|backInOut|anticipate)[\x27"]|(cubic-bezier\(|ease: *\[)([^,]*,|([^,]*,){3})[ _]*(-|1\.[0-9]*[1-9]|[2-9])|scale: *0 *[,}]|\bscale-0\b|while(Hover|Tap|Focus)=|(initial|animate|exit)=\{\{[^}]*\b(width|height|maxHeight|top|left|margin|padding|boxShadow|filter|backdropFilter|borderRadius)[A-Za-z]*:' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 off-scale motion: a duration off 0, 100, 150, 200 and 300 (duration-500 only on a media hover zoom), ease-in or ease-out, a stagger step above 0.05
rg -nH 'duration-([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})\b|duration-\[|\b([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})ms\b|(^|[^0-9.])(0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)|1\.[0-9]+)s\b|duration: *([1-9]\b|0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)\b)|\bease-(in|out|in-out)\b|(staggerChildren: *|stagger\( *)(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *[^,}]*\b(i|index|idx)\b *\* *(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *(0?\.0[6-9]|0?\.[1-9]|[1-9])[0-9.]* *\* *\b(i|index|idx)\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 read by hand: a duration or delay that is not a number, every index delay (allowed: 0.05 or less on the first 6 items, as Math.min(i, 5) * 0.05, never on table rows)
rg -nH '([dD]uration|[dD]elay): *[^0-9 .]|delay: *[0-9.]+ *\*' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C3 state color: raw palette on a line that names a state
rg -nH '\b(text|bg|border|ring|fill|stroke)-(red|rose|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet)-[0-9]{2,3}\b' <files> | rg -i ':[0-9]+:.*\b(success|paid|active|completed?|done|approved|online|healthy|live|error|failed|critical|overdue|declined|rejected|cancell?ed|blocked|expired|warning|pending|degraded|due|info|processing|running|scheduled|queued)\b' | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 structure: a raw table, select or fixed overlay, framer-motion, Math.random() or Date.now()
rg -nH '<table\b|<select\b|fixed inset-0|from ["\x27]framer-motion|Math\.random\(|Date\.now\(' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 on a base-* style only (skip on radix-*): Radix asChild
rg -nH '\basChild\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
```
Fix every hit, run again, stop after the second run and list what is left with file and line. A C4 hit stays only when a comment in the file says why. These also stay, named on hand-back line 2: C1 on the motion.md sidebar row or on will-change set in a drag handler and removed when it ends; C2 on a media hover zoom; `Math.min(i, 5) * 0.05`.
No `rg` (a missing one also prints nothing): run each first pattern with your own search tool, skipping comment lines. No shell: read each changed file for what each `#` line names.
## Hand-back
End your reply with these 5 lines:
1. Installed: each item with its `previewUrl`.
2. Motion: each animated element and its [motion.md](./motion.md) table row, or "none".
3. States: covered, or which are missing.
4. Checks: C1 to C4, screenshots or "not visually verified", the audit checklist, typecheck, lint; each one run, or "not run" and why.
5. Deviations: any rule broken or check hit left, with file:line and why.
+44
View File
@@ -0,0 +1,44 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the ReUI components with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (a full dashboard or settings page). Pro or Ultimate license at install. Its surfaces, text and borders use your theme tokens; its category accents (chart series, tags, avatars) are fixed palette colors paired for light and dark.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`), plus a hover-animated variant (`@reui/icons/animated/<style>/<name>`) only for icons that carry `installAnimated`. Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
ReUI items install through the shadcn CLI as `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a ReUI tool returned it (`search`, a getter, `get_install_command`). The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## ReUI items vs shadcn/ui components
- **ReUI items** (the components, `c-*` examples, blocks, Motion Icons) install as `@reui/<name>`.
- **shadcn/ui components** that ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. They install from shadcn by bare name: `npx shadcn@latest add button --yes`.
- **`badge` and `alert` exist in both registries.** `@reui/badge` and `@reui/alert` are ReUI's components (the extra variants in [components.md](./components.md)); bare `badge` and `alert` are shadcn's.
- **In an item's `registryDependencies`**, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both for you.
- **Templates are not registry items.** Download one from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs a template.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks take surfaces, text and borders from your theme tokens, so a theme change re-tints them; category accents stay fixed by design.
## Free vs premium
- **Free, no license key:** the ReUI components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate) and Motion Icons (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
- **Templates** are downloads, not installs: free ones need only a ReUI account, and each template's page on reui.io says what the others need.
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+37
View File
@@ -0,0 +1,37 @@
# Theming: styles, radius, density and sizes
## Read your style
`components.json` `style` is `<base>-<style>`: the part after the first `-` names the style (`base-nova` is nova on Base UI). The style is baked into `components/ui` and `components/reui` at install, so the installed files hold your real values: read your installed `button.tsx` (usually `components/ui/button.tsx`) for heights, radius and padding before you build anything that sits beside a control.
## The style ladder
<!-- gen:style-ladder:start -->
| Style | Button `sm` / default / `lg` | Button radius | Card radius, shadow | Card spacing | Button text |
| ----- | ---------------------------- | -------------- | ---------------------------------------------------- | ------------ | -------------------- |
| vega | `h-8` / `h-9` / `h-10` | `rounded-md` | `rounded-xl`, `shadow-xs` | 6, sm 4 | `text-sm` |
| nova | `h-7` / `h-8` / `h-9` | `rounded-lg` | `rounded-xl` | 4, sm 3 | `text-sm` |
| maia | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-2xl` | 6, sm 4 | `text-sm` |
| lyra | `h-7` / `h-8` / `h-9` | `rounded-none` | `rounded-none` | 4, sm 3 | `text-xs` |
| mira | `h-6` / `h-7` / `h-8` | `rounded-md` | `rounded-lg` | 4, sm 3 | `text-xs/relaxed` |
| luma | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-4xl`, `shadow-md` | 6, sm 4 | `text-sm` |
| sera | `h-9` / `h-10` / `h-11` | `rounded-none` | square, `shadow-sm` | 8, sm 5 | `text-xs`, uppercase |
| rhea | `h-7` / `h-8` / `h-9` | `rounded-2xl` | `rounded-[min(var(--radius-4xl),24px)]`, `shadow-sm` | 5, sm 4 | `text-sm` |
<!-- gen:style-ladder:end -->
Card spacing is the `--card-spacing` step (`--spacing(n)`) a Card pads with; `Card size="sm"` uses the sm step. The default Button text weight is `font-medium` (sera: `font-semibold`).
## What the style owns
- Never set what the style owns on its parts: radius (except `rounded-full`), height, horizontal padding, shadow, ring or font size. Pick `size` and `variant` instead.
- A panel is a `Card` or a `Frame`, never a div with `rounded-*`, `border` and `bg-card`; set density with `Card size="sm"` or Frame `spacing`.
- A bespoke element beside a control (a chip, a custom trigger) takes its height and radius from that control's row above.
- A focusable control you build copies the focus classes of your installed `input.tsx`.
- A variant before a class: `className` is for layout (placement, width, gap). `size-*` when width equals height, `cn()` for conditional classes (never a template literal), a scale step before an arbitrary value (`w-60`, not `w-[240px]`), no inline `style` for color or spacing, and no `z-index` on dialogs, sheets or popovers.
- Change the look through the theme CSS variables in `:root` and `.dark` (`--radius`, `--primary` and the rest) or with a style switch (a new `style` in `components.json`, which means reinstalling the components; ask the user first). Never edit installed files to restyle them.
## Components with no per-style values
`event-calendar`, `gantt`, `icon-stack`, `kanban`, `rating`, `scrollspy`, `sortable` and `timeline` install the same file in every style. Their look comes from your theme tokens and the parts you put inside them (a `Card` in a kanban column follows the style), so never copy another style's radius onto them.
+66
View File
@@ -0,0 +1,66 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## When to reach for ReUI vs plain shadcn
- A full page or section (dashboard, billing, auth, pricing, settings): `compose_page` first (step 1).
- A data table with sorting, filtering, pagination or virtualization: the `data-grid` component, never a hand-rolled `<table>`.
- A drag-and-drop board: the `kanban` component.
- Column filters, a date range, a tree, a stepper and the like: the matching ReUI component.
- A single generic control shadcn already has (Button, Dialog, Select): plain shadcn.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent)` FIRST (`sections`, 1 to 10, caps how many sections, default 8), before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (a ReUI component), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid" })` (on a free account use `type: "example"`).
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Pick with the guidance, not the title.** Look-alikes are common (dialog, sheet and drawer; dozens of data-grid examples and app shells). Before installing, read the item's guidance: `get_component` returns `whenToUse` and `whenNotToUse`, `get_example` and `get_block` return `bestFor` (what sets the item apart from its siblings), `agentNotes` and `whenNotToUse`, and a block adds `categoryGuidance` (its category's key note), and `get_examples` rows carry `bestFor`. Each `whenNotToUse` entry names the item to use instead and its `type` (`component` -> `get_component`, `example` -> `get_example`, `block` -> `get_block`, `block-category` "<group>/<category>" -> `search({ query, type: "block", category: "<category>" })`); when the user's situation matches its `when`, switch. Apply every `agentNotes` line before you ship: it names the real identifiers to replace or wire (sample data, stubbed actions, required props). An item with no guidance yet has none of these keys; fall back to its description and thumbnail.
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**Show the preview.** List every item with its `previewUrl` (a live preview page). Where a result carries `thumbnail` (blocks, `c-*` examples and icons), show the image as markdown linked to `previewUrl`, so the user sees it before you install.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. Write against your project's base (see [components.md](./components.md)). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to the active theme: semantic tokens for anything that carries meaning, raw palette only for categorical identity and always with its `dark:` pair (see [color.md](./color.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, motion that leaves installed components alone, and mobile-first responsiveness (see [layout.md](./layout.md) and [motion.md](./motion.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+74
View File
@@ -0,0 +1,74 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; Pro unlocks premium blocks, Ultimate adds Motion Icons, and either removes the daily request limit by default. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (`sections` is a number, 1 to 10: the most sections to return, default 8); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Only some icons have a real hover animation: `animated: true` returns only those, and a concept with none comes back empty with an `animatedNote`. Each icon carries a `thumbnail` of its 4 styles; look at it before trusting a name. An old icon name still resolves (`renamedFrom` names it), unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word and `oldNameNotes` names the icon the old name installs. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale. With no `topic` it returns the core workflow, the topic index and the installer; `topic` (for example `motion`) returns that one file in full. Compare its `version` with the one stamped atop your local `SKILL.md`.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Commands (MCP prompts)
In Claude Code the ReUI MCP adds these prompts; other agents show MCP prompts under their own names, or not at all. Plain language runs the same loop.
- `/mcp__reui__build <what>`: compose a page, section or feature (plan, install, read the API, adapt, audit).
- `/mcp__reui__add <item>`: find, install and wire in 1 component, example, block or icon.
- `/mcp__reui__fix [target]`: fix ReUI usage (undocumented props, a base or radix mismatch, missing states, accessibility, scroll).
- `/mcp__reui__improve [target]`: refine existing ReUI UI (hierarchy, density, states, responsive, motion).
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- Icon results and `get_icon` include `animated: true` and `installAnimated` only when the icon has a real hover animation (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`). Use the strings given; never build an animated path by hand, it installs a static copy for every other icon.
- Blocks, `c-*` examples and icons carry `thumbnail`, an absolute URL to a hosted preview image (light theme; blocks 1080x720, examples 900x600, icons 640x200 with the 4 styles side by side). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Guidance is getter-only, never on search rows: `get_component` adds `whenToUse`, `whenNotToUse` and `agentNotes`; `get_example` and `get_block` add `bestFor`, `agentNotes` and `whenNotToUse`, and `get_block` also `categoryGuidance`; `get_examples` rows add `bestFor`. Answers carry the key lines; the item page's `.md` twin has every line. Each `whenNotToUse` entry is `{ when, instead, type }`: fetch `instead` with the getter its `type` names.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"reui": {
"url": "https://mcp.reui.io/api/mcp"
}
}
}
+102
View File
@@ -0,0 +1,102 @@
---
description: Frontend — shadcn primitives + ReUI PRO blocks; CLI-first; kit patterns
globs: apps/web/**/*,packages/ui/**/*
alwaysApply: false
---
# Frontend — shadcn/ui + ReUI PRO
**Источник истины:** MCP **`user-reui`** (pages/blocks/KPI/settings) + MCP `plugin-shadcn-shadcn` (primitives) + docs. Не выдумывать UI.
Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-mcp.mdc`](reui-mcp.mdc). Patterns — [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
| Документ | URL |
|----------|-----|
| **shadcn Components** | https://ui.shadcn.com/docs/components |
| **ReUI Blocks** | https://reui.io/blocks |
| **ReUI llms.txt** | https://reui.io/llms.txt |
| **ReUI Settings** | https://reui.io/blocks/application/settings |
| **Installation** | https://ui.shadcn.com/docs/installation |
| **Monorepo** | https://ui.shadcn.com/docs/monorepo |
| **Forms (RHF)** | https://ui.shadcn.com/docs/forms/react-hook-form |
## Шаг 0 — перед любым UI-кодом
0. Найти существующие `reui-kit/*` / shared
1. **Pages / KPI / settings / enterprise** → MCP `user-reui` (`search` → `get_block` / `compose_page`) + cite `previewUrl`
2. **Primitives** → MCP `plugin-shadcn-shadcn` + `pnpm dlx shadcn@latest docs <component>`
3. CLI add из `apps/web` → adapt
**Новая страница** → сначала ReUI PRO `compose_page` / blocks ([reui.io/blocks](https://reui.io/blocks)), не ui.shadcn.com/blocks как primary.
## Shared / kit (обязательно)
| Component | Файл |
|-----------|------|
| `PageShell` | `page-shell.tsx` |
| `ResourcePage` | `reui-kit/resource-page.tsx` |
| `OpsDashboard` / `KpiStatGrid` / `QuickActionGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx`, `quick-action-grid.tsx` |
| `SettingsShell` | `reui-kit/kanban-board.tsx` |
| `DetailPanel` | `reui-kit/detail-panel.tsx` |
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
| `EmptyState` | `empty-state.tsx` |
| `QueryState` | `query-state.tsx` |
| `ConfirmDialog` | `confirm-dialog.tsx` |
| `StatusBadge` | `status-badge.tsx` |
| `FormSheet` | `form-sheet.tsx` |
| `FormField` | `form-field.tsx` |
| `LoadingButton` | `loading-button.tsx` |
| `SettingRow` | `setting-row.tsx` |
**Не использовать как эталон:** устаревшие `PageHeader` / `SectionCards` / `DataGridCard` / `TableCard` (если удалены или мёртвы).
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
## Шаг 1 — CLI
```bash
cd apps/web
pnpm dlx shadcn@latest add button field input ...
pnpm dlx shadcn@latest add @reui/frame @reui/data-grid @reui/filters
pnpm dlx shadcn@latest add @reui/stats-12 @reui/card-35 @reui/settings-16 @reui/auth-13
pnpm dlx shadcn@latest apply b2fA --only theme -y
```
## Приоритет композиции
1. `@authportal/ui/components/*` (primitives)
2. ReUI PRO block → adapt (`blocks/` reference + kit)
3. `reui-kit/*` + shared
4. Domain-обёртка
## Запрещено
| ❌ | ✅ |
|----|-----|
| `<table>`, raw select | DataGrid / `Select` |
| `bg-emerald-*` | semantic / ReUI `variant` |
| Card как ops-shell | ReUI `Frame` |
| Hand-roll KPI | `KpiStatGrid` / [stats-12](https://reui.io/preview/base/stats-12) |
| `space-y-*` | `flex` + `gap-*` |
| `@/components/ui/*` | `@authportal/ui/components/*` |
## Эталоны проекта
| Зона | Файл | Preview |
|------|------|---------|
| Shell | `layout/app-shell.tsx` | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| Login | `routes/login.tsx` | [auth-13](https://reui.io/preview/base/auth-13) |
| Dashboard | `routes/_auth/index.tsx` | [stats-12](https://reui.io/preview/base/stats-12) / dashboard-1 |
| Lists | `ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Settings | `settings/integrations.tsx` | [settings-16](https://reui.io/preview/base/settings-16) |
## Чеклист
- [ ] `user-reui` + preview/docs для зоны
- [ ] Frame surface
- [ ] CLI add при новых items
- [ ] `pnpm --filter web build`
## Язык
Ответы — русский. Commits — [`commit-messages-ru.mdc`](commit-messages-ru.mdc).
+194
View File
@@ -0,0 +1,194 @@
---
description: Единые UI-паттерны web — shared components, docs workflow, матрица стандартизации
globs: apps/web/**/*
alwaysApply: false
---
# Frontend UI Patterns
См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [Components](https://ui.shadcn.com/docs/components), [ReUI llms.txt](https://reui.io/llms.txt).
## Docs workflow (обязательно)
0. Codegraph / поиск существующих shared/domain / `reui-kit`
1. Skill ReUI + shadcn — component selection
2. **Primary:** MCP `user-reui` — search → `get_block` / `compose_page` (`surface: "frame"`) — **всегда cite `previewUrl` + `docsUrl`**
3. Primitives: MCP `plugin-shadcn-shadcn` + `pnpm dlx shadcn@latest docs <component>`
4. CLI: `cd apps/web && pnpm dlx shadcn@latest add @reui/...` → сверить API
5. Код по examples + docs (только после MCP ↔ docs)
6. Context7 — **только** TanStack Router/Query, Recharts
7. MCP `validate_usage` / `get_audit_checklist` — перед merge
## Surface
Проект использует **ReUI Frame** (`surface: frame`), не shadcn Card как оболочку list/ops-экранов. Эталон списка: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
## Обязательные референсы по зонам
| Зона | Preview |
|------|---------|
| KPI | [stats-12](https://reui.io/preview/base/stats-12) — EvoBGP hybrid SoT |
| Quick Actions | [stats-12](https://reui.io/preview/base/stats-12) · [card-12](https://reui.io/preview/base/card-12) → `QuickActionGrid` |
| List | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Settings | [settings-16](https://reui.io/preview/base/settings-16), [Application Settings](https://reui.io/blocks/application/settings) |
| Settings rows / Health-check | [settings-2](https://reui.io/preview/base/settings-2), [settings-3](https://reui.io/preview/base/settings-3) |
| Auth | [auth-13](https://reui.io/preview/base/auth-13) |
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
| Forms | [form-7](https://reui.io/preview/base/form-7) |
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
**SettingRow:** `FieldSeparator` opt-in (`separated`); не между toggle-row и nested fields (Health-check). Settings-секции — отдельные Frame + `gap`, без hairline под PageHeader.
## Иерархия компонентов
```
@authportal/ui/components/* ← shadcn CLI (packages/ui)
@/components/reui/* ← ReUI CLI @reui/* (apps/web)
apps/web/src/components/ ← shared + domain + layout
page-shell.tsx
reui-kit/
resource-page.tsx ← list: Frame + line Tabs + Filters + DataGrid
kanban-board.tsx ← kanban + SettingsShellSkeleton
detail-panel.tsx ← detail: Frame header/metrics
settings-shell.tsx
ops-dashboard.tsx ← KPI stats-12 + charts
catalog-board-toggle.tsx
empty-state.tsx
query-state.tsx
confirm-dialog.tsx
status-badge.tsx
form-sheet.tsx
form-field.tsx
loading-button.tsx
layout/ ← app-shell, site-header, app-sidebar
domain-* ← бизнес-компоненты
```
## Матрица стандартизации
| Элемент | Shared | Primitive |
|---------|--------|-----------|
| Page wrapper | `PageShell` | — |
| List page | `ResourcePage` | ReUI `Frame` + `data-grid` + `filters` + shadcn `Tabs` `variant="line"` |
| Catalog / Board | `CatalogBoardToggle` + `ResourcePage` / `SettingsShell` | `?view=board` на `/groups`, `/services` |
| Kanban | `SettingsShell` / `SettingsShellSkeleton` | ReUI `kanban` + `Frame` |
| Detail | `DetailPanel` | ReUI `Frame` |
| Settings | `SettingsShell` | — |
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid |
| Quick Actions | `QuickActionGrid` | Frame tiles + Badge «Перейти» |
| Empty | `EmptyState` | `Empty` |
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` |
| Form field | `FormField` | `Field`, `Input`, `Select` |
| Submit button | `LoadingButton` | `Button`, `Spinner` |
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
| Nav | `AppSidebar` (`isActive` обязателен) | `Sidebar` |
| Breadcrumbs | `SiteHeader` | `Breadcrumb` |
| Dates | `lib/format.ts` | — |
## Header actions
Независимые CTA в header → `flex shrink-0 flex-wrap items-center justify-end gap-2`.
**Не** оборачивать в `ButtonGroup` (он склеивает кнопки). `ButtonGroup` — только для связанных контролок (filter chips и т.п.).
Max 1 primary (`default`) на экран; остальные `outline` / `ghost`.
Toggle «Доска» / «К каталогу» — всегда `outline` в `primaryAction` / `SettingsShell.toolbarActions` (не отдельный Frame-shell).
## Line tabs (project standard)
Эталон: [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) + counted [filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
```tsx
<TabsList variant="line" className="gap-5">
<TabsTrigger value="…" className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3">
<span>Label</span>
<span className="bg-muted text-muted-foreground … tabular-nums rounded-md">{count}</span>
</TabsTrigger>
</TabsList>
```
- Примитив: [`packages/ui/.../tabs.tsx`](../../packages/ui/src/components/tabs.tsx) — line = `h-auto`, без `flex-1`, без `dark:data-active:bg-input/30`, underline `after:bottom-0`
- Active = яркий текст + **foreground underline**, без фона
- Count pill всегда `bg-muted`
- Active state — Base UI `data-active`, не Radix `data-[state=active]`
- Не трогать internals `reui/date-selector`
## Catalog / Board (`/groups`, `/services`)
| Режим | UI | Search |
|-------|-----|--------|
| Catalog (default) | `ResourcePage` + primary create | omit / `view=catalog` |
| Board | `SettingsShell` DnD | `?view=board` |
- Groups tabs: Все / С доменами / Пустые
- Services tabs: Все / Включены / Выключены / Без группы
- DnD только на board; kanban hooks/cards не удалять
## Dashboard KPI
Эталон: [stats-12](https://reui.io/preview/base/stats-12) — icon tile + value + label + optional badge/footer; клик через `to` (`Link`) / `onSelect`. Compact strip: [card-35](https://reui.io/preview/base/card-35).
## Sidebar
- Каждый `SidebarMenuButton` получает `isActive` от pathname (`useRouterState`)
- `--sidebar-accent` в `AppShell` — заметный mix primary (~14%), не 5%
## Overlay selection
| Сценарий | Компонент |
|----------|-----------|
| Create/edit форма | `Sheet` |
| Destructive confirm | `AlertDialog` via `ConfirmDialog` |
| Modal preview | `Dialog` |
## Block registry
| Зона | Block / эталон |
|------|----------------|
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| List + tabs + filters | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Line tabs | [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) |
| KPI | [stats-12](https://reui.io/preview/base/stats-12) — на dashboard, certificates, domains, groups, services |
| Login | [auth-13](https://reui.io/preview/base/auth-13) |
| Settings | [settings-16](https://reui.io/preview/base/settings-16) / [settings-6](https://reui.io/preview/base/settings-6) |
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
## Spacing
```
AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5
(--sidebar-width: 240px; header h-12; AppSwitcher + AppsMenu — Shared App Shell chrome)
PageShell: gap-4 md:gap-6
Title/desc: gap-px
Card/Frame grid: gap-4 (dashboard denser: gap-2 md:gap-3)
FieldGroup: gap-4
Item list: gap-2
Toolbar / header actions: gap-2
Tabs list (line): gap-5
```
Shared chrome (vps-tracker / CFDM / EvoBGP): см. [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md) — секция **Shared App Shell chrome**. Preview: [app-shell-12](https://reui.io/preview/base/app-shell-12).
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner / plain «Загрузка…» без Skeleton.
## UX/UI (состояния данных)
Каждый блок: **default, hover, focus, disabled, empty, loading, error**.
- **Loading** — `Skeleton` / `ResourcePage` skeleton / `SettingsShellSkeleton` / `OpsDashboard` skeleton, не Spinner на странице
- **Empty** — `EmptyState` с CTA
- **Zero-results** — message внутри DataGrid (+ «Сбросить»)
- **Error** — `QueryState` / `Alert` + `onRetry`
- **Overflow** — `truncate`, `max-w-*`, `Tooltip`; `tabular-nums` для чисел
- **Density** — operational (`dense` Frame/DataGrid); max 1 primary CTA
- **A11y** — `aria-invalid`, `aria-label`/`sr-only` на icon-only, `aria-current="page"` на active nav/settings
## Button hierarchy (max 1 primary per screen)
1. `default` — главный CTA
2. `outline` — вторичные действия
3. `ghost` / `link` — навигация, cancel
4. `destructive` — только с `ConfirmDialog`
+30
View File
@@ -0,0 +1,30 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 + IconTile)
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
## MUST
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@authportal/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
- Другой size / radius / solid brand fill вместо `elevated`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без IconTile
+120
View File
@@ -0,0 +1,120 @@
---
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
alwaysApply: true
---
# ReUI MCP — обязательно (PRO + free)
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
| Документ | URL |
|----------|-----|
| **Introduction** | https://reui.io/docs |
| **llms.txt** | https://reui.io/llms.txt |
| **Get Started** | https://reui.io/docs/get-started |
| **Styling** | https://reui.io/docs/styling |
| **Registry** | https://reui.io/docs/registry |
| **MCP** | https://reui.io/docs/mcp |
| **Agent Skills** | https://reui.io/docs/agent-skills |
| **Cursor MCP** | https://reui.io/docs/cursor |
| **Blocks** | https://reui.io/blocks |
| **Settings blocks** | https://reui.io/blocks/application/settings |
| **License** | https://reui.io/docs/license-setup |
| **Base UI components** | https://reui.io/docs/components/base/<name> |
## Primary MCP
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
## Когда ReUI vs shadcn
| Задача | Registry | Импорт |
|--------|----------|--------|
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@authportal/ui/components/*` |
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
| Filters | `@reui` | `@/components/reui/filters` |
| Frame surface | `@reui` | `@/components/reui/frame` |
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban / timeline / stepper / tree | `@reui` | `@/components/reui/*` |
| Event Calendar / Gantt / Icon Tile (registry, 20 free) | `@reui` | CLI `@reui/<name>` → `@/components/reui/*` при установке |
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
## MCP workflow
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
2. Страница целиком → `compose_page`; секция → `get_block`
3. `get_component` для API primitives из `componentsUsed`
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
5. Post-add: shadcn imports → `@authportal/ui/components/*`
6. Adapt by reuse → kit / route
7. `validate_usage` + `get_audit_checklist`
## Размещение
| Слой | Путь | Импорт |
|------|------|--------|
| shadcn | `packages/ui/src/components/` | `@authportal/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `timeline`, `icon-tile`, `icon-stack`
**Kit:** `KpiStatGrid`, `QuickActionGrid`, `AdminUsersGrid` (lists = PageShell + Frame + DataGrid; `ResourcePage` не list-kit)
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
## License
```env
# .env.local (gitignored)
REUI_LICENSE_KEY=
```
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
## Эталоны preview
| Зона | Preview |
|------|---------|
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
| List | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 |
| Auth | https://reui.io/preview/base/auth-13 |
| Shell | https://reui.io/preview/base/app-shell-12 |
| Empty | https://reui.io/preview/base/empty-state-12 |
## Запрещено
- Копипаст с reui.io без CLI
- ReUI в `packages/ui` / импорт как `@authportal/ui`
- Radix-варианты docs — только Base UI
- Raw `bg-emerald-*` вместо ReUI `variant`
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO
## Чеклист
- [ ] `user-reui` search/get_block + previewUrl
- [ ] `surface: frame`
- [ ] CLI add из `apps/web` при новом item
- [ ] Kit / `@/components/reui` / `@authportal/ui` — правильный слой
- [ ] `pnpm --filter web build`
+17
View File
@@ -0,0 +1,17 @@
---
description: ReUI - read the reui skill before any UI work
alwaysApply: true
---
## ReUI design rules (every UI edit, follow-ups included)
This project uses an existing design system: ReUI on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here, and other design skills lose where they disagree.
1. Use installed components first (`components/ui`, `components/reui`): tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` or `AlertDialog`, toasts `sonner`. Never set height, radius, padding, shadow or motion on them; use `size`, `variant`, `spacing`.
2. Color: semantic tokens. State: `<Badge variant="success-light">` from `@/components/reui/badge` at default size, or Alert variants. Raw palette only for categorical identity, with its `dark:` pair.
3. Spacing: `gap-*` on the parent: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections. Type: `text-sm` body, `text-xs` meta, nothing smaller; never resize FrameTitle or CardTitle.
4. Motion: installed parts already animate; never retime them. Yours: hover `transition-colors`; reveal `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]`; transform and opacity only; never `transition-all`, bounce, or a stagger on table rows or lists of 20+; at most 1 entrance per view; `MotionConfig reducedMotion="user"` plus `motion-reduce:`.
5. Every data region: empty state with an action, `Skeleton` loading, inline error with Retry.
6. Before UI code, write the plan from `.agents/skills/reui/SKILL.md` in your reply. Before you finish, run its self-check on the files you changed and fix every hit (2 rounds at most). Full rules: `.agents/skills/reui/SKILL.md`. If that folder is missing, call the `reui` MCP tool `get_agent_skill`.
Managed by the ReUI installer. This file is replaced on the next run.
+44
View File
@@ -0,0 +1,44 @@
---
description: shadcn MCP — primitives secondary; pages/blocks → user-reui first
alwaysApply: true
---
# shadcn MCP — primitives (secondary)
Перед UI-задачей: **сначала** MCP `user-reui` для pages / KPI / lists / settings / Frame ([`reui-mcp.mdc`](reui-mcp.mdc)).
MCP `plugin-shadcn-shadcn` — для **primitives** `@shadcn` и registry sync, не вместо PRO blocks.
CLI и docs — **после** MCP, по команде из `get_add_command_for_items`.
## Порядок (primitives)
0. Поиск существующих shared / `reui-kit`
1. **`get_project_registries`**
2. **`search_items_in_registries`** — primitive / example
3. **`get_item_examples_from_registries`**
4. **`get_add_command_for_items`** → CLI из `apps/web`
5. `pnpm dlx shadcn@latest docs <component>` — сверить API
6. Адаптировать под TanStack → `apps/web/src/`
7. Context7 — только TanStack / Recharts / не-shadcn
8. **`get_audit_checklist`** — перед merge
## Когда
| Задача | MCP |
|--------|-----|
| Новая страница / KPI / list / settings | **`user-reui`** ([`reui-mcp.mdc`](reui-mcp.mdc)) |
| Нет примитива в `@authportal/ui` | `plugin-shadcn-shadcn` → add |
| Сомнение в props примитива | examples + docs CLI |
## Запрещено
- Писать UI по памяти
- Ставить ui.shadcn.com/blocks выше ReUI PRO
- Самописные примитивы при наличии registry item
## Сервер
- **MCP:** `plugin-shadcn-shadcn` (+ `user-reui` primary)
- Schema: `mcps/plugin-shadcn-shadcn/tools/`
Связанные: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
+38
View File
@@ -0,0 +1,38 @@
---
description: shadcn/ui + ReUI PRO — глобальные UI-принципы; ReUI PRO выше shadcn
alwaysApply: true
---
# shadcn/ui + ReUI PRO — правила проекта
UI строится по [shadcn/ui](https://ui.shadcn.com/docs/installation) + **ReUI PRO** (`@reui`): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [ReUI Get Started](https://reui.io/docs/get-started), [llms.txt](https://reui.io/llms.txt).
**Иерархия:** ReUI PRO (`user-reui`) **выше** базового shadcn. Pages / KPI / lists / settings / shell → ReUI; shadcn — primitives.
**Первый шаг UI-задачи:**
1. MCP **`user-reui`** для pages / KPI / data-grid / settings / Frame / Quick Actions (`surface: frame`)
2. MCP **`plugin-shadcn-shadcn`** для primitives `@shadcn`
См. [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc). Contract: [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md).
## Frontend
Детали: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc).
Кратко: MCP → CLI из `apps/web` → Block → kit → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**.
## Стек
- Monorepo: `apps/web` + `packages/ui` (`@authportal/ui`), pnpm workspaces
- Vite + TanStack Router/Query + shadcn **base-nova** + ReUI **@reui**
- Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y`; ReUI tokens — [Styling](https://reui.io/docs/styling)
- License: `REUI_LICENSE_KEY` в `apps/web/.env.local`
## Backend → UI
При правках API с экранами: [`backend-api-ui.mdc`](backend-api-ui.mdc). Backend: `apps/api` (Fastify + Drizzle).
## Язык
Русский. Commits: [`commit-messages-ru.mdc`](commit-messages-ru.mdc).
+83
View File
@@ -0,0 +1,83 @@
---
name: reui
description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind. Use for any UI work in a project with @reui in components.json or a components/reui folder, and in any shadcn project that asks for data grids, kanban boards, dashboards or full pages: building or restyling pages, forms and tables; colors, theme tokens and dark mode; spacing, typography and layout; animation and hover motion; installing ReUI components, examples, Pro blocks or Motion Icons. Works with the ReUI MCP."
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
---
> **ReUI skill version `68f0e6fa91`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer in the mode it was installed with to update this skill: `get_agent_skill` -> `install.recommended` from the project root, with `REUI_GLOBAL=1` before `node` when this file is `~/.claude/skills/reui/SKILL.md` or `~/.agents/skills/reui/SKILL.md` (a global install). Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI
ReUI is an existing design system on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here.
It ships free components, free `c-*` examples, Pro blocks and Ultimate Motion Icons through the shadcn CLI; the ReUI MCP finds them and serves their APIs.
## Order of authority
1. The installed style (`components.json` `style`, baked into `components/ui` and `components/reui`): it owns radius, height, padding, shadow and overlay motion.
2. The installed component's variants and props (`size`, `variant`, `spacing`).
3. These rules and the topic files.
4. Other design skills, then your taste: where they disagree with 1 to 3, ReUI wins. Never redefine `--ease-out` or any Tailwind built-in.
## Before you write UI code, write this plan in your reply
1. Surface: operational, marketing or flow.
2. Archetype: 1 from [compose.md](./rules/compose.md).
3. Container family: Frame-led, Card-led, or Cards for the KPIs and a Frame for the table.
4. Density: dense (`size="sm"` on controls that take it) or default.
5. Regions: top to bottom, each with the item that fills it.
6. States: empty, loading and error for each data region.
7. Motion: none, or the 1 entrance.
Then build exactly that plan; if you deviate, say where.
## The loop
1. **Find**: `compose_page` for a full page, else `search` with `type` and `component` hints.
2. **Install**: `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a tool returned it (shadcn/ui parts install by bare name: `npx shadcn@latest add button --yes`).
3. **Read the API on your base**: `components.json` style `base-nova` is Base UI, `radix-nova` is Radix. Call `get_component([...componentsUsed])` once, then install 1 `get_examples` example and read its files.
4. **Adapt**: change data, copy and props; keep the structure and classes.
5. **Check**, after every UI edit, follow-ups included: `validate_usage` on any name you did not read, the self-checks C1 to C4 in [quality.md](./rules/quality.md), [anti-patterns.md](./rules/anti-patterns.md), `get_audit_checklist`, typecheck, lint. Hand back in the 5 lines quality.md gives.
Show every item's `previewUrl` whenever you list, recommend or install it.
## Rules that always apply
- **Owners**: tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` (destructive confirms `AlertDialog`), toasts `sonner`; never hand-roll.
- **Color**: semantic tokens for meaning; state colors through Badge or Alert variants (`@reui/badge` and `@reui/alert`, not shadcn's); raw palette only for categorical identity, always with its `dark:` pair.
- **Contrast**: never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
- **Shape**: no `rounded-*` except `rounded-full`, and no `shadow-*`, `h-*` or `px-*` on Button, Input, SelectTrigger, Card or Frame; pick `size` and `variant`. A panel is a Card or Frame, never a div with `rounded-lg border`.
- **Spacing**: `gap-*` on the parent, not `space-y-*`; `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections; title to description `gap-0.5`.
- **Type**: `text-sm` body, `text-xs` meta (controls keep their style's size), `font-medium` or `font-semibold`, headings `tracking-tight`, numbers `tabular-nums`; nothing below `text-xs` except illustrative mini-UI.
- **Motion**: installed components own their motion. Your own code uses `transition-colors` for hover and press, `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` to reveal, `duration-300` with the same curve at most (media hover zoom: 500), and `SPRING` (`{ type: "spring", visualDuration: 0.25, bounce: 0 }`) for drag and reorder. At most 1 entrance animation per view (a stagger counts as 1). Animate only `transform` and `opacity`, CSS before motion/react, never set state per frame; `<MotionConfig reducedMotion="user">` once at the root plus `motion-reduce:` on CSS, where reduced motion means a calm fade, not nothing.
- **Surfaces**: prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card. Icons come from 1 library, the `iconLibrary` in `components.json` (on Ultimate, 1 Motion Icon on the primary action is the exception).
- **States**: each data region has empty (1 action), loading (a `Skeleton` in the real layout) and error (Retry) states; light and dark work.
- **Never invent**: never write a prop, variant or `@reui/` name you did not read in an `api`, an installed file or a tool result.
## Topics
Always read `compose` before building a screen, and `quality` plus `anti-patterns` at the Check step. Besides those, read only the topic files the task touches, at most 4 (for example `color`, `motion`, `layout` and `components`).
Installed skill: open the file. MCP only: call `get_agent_skill` with the topic, or fetch `https://mcp.reui.io/skill/reui/<file>`.
| Topic | File | Read when |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| `compose` | [rules/compose.md](./rules/compose.md) | before building any screen or section |
| `workflow` | [rules/workflow.md](./rules/workflow.md) | `compose_page`, search hints, installing |
| `adapting` | [rules/adapting.md](./rules/adapting.md) | changing an installed block or example |
| `components` | [rules/components.md](./rules/components.md) | `data-grid`, `filters`, `kanban` and the rest |
| `color` | [rules/color.md](./rules/color.md) | any color, status, chart or dark mode choice |
| `theming` | [rules/theming.md](./rules/theming.md) | the styles, radius, density and sizes |
| `motion` | [rules/motion.md](./rules/motion.md) | any transition, animation or motion/react |
| `layout` | [rules/layout.md](./rules/layout.md) | house defaults, hierarchy, surfaces, states |
| `icons` | [rules/icons.md](./rules/icons.md) | icon imports, Motion Icons |
| `quality` | [rules/quality.md](./rules/quality.md) | the done gate: self-checks, a11y, hand-back |
| `anti-patterns` | [rules/anti-patterns.md](./rules/anti-patterns.md) | the Check step: drift pairs, stale habits |
| `registry` | [rules/registry.md](./rules/registry.md) | free vs Pro vs Ultimate, base vs radix |
| `cli` | [rules/cli.md](./rules/cli.md) | `components.json`, license key, install flags |
| `tools` | [tools.md](./tools.md) | MCP result shapes, errors, quota, commands |
## Plans
Free covers components, `c-*` examples, this skill and the MCP (a ReUI account and a daily allowance). Pro adds blocks. Ultimate adds Motion Icons. A plan miss is a normal answer with `locked: true`; keep building with what the plan covers.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "ReUI"
short_description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind."
default_prompt: "Use ReUI to build this UI with installed components and the ReUI motion and color rules."
+40
View File
@@ -0,0 +1,40 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- Never read `window`, `localStorage`, `location`, `Date.now()` or `Math.random()` in render or in a `useState` initializer; read them in an effect.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something, and the layout must still hold with long names, empty values and crowded data.
+117
View File
@@ -0,0 +1,117 @@
# Anti-patterns
Read this at the Check step, after the self-checks in [quality.md](./quality.md). Each entry: an `Incorrect:` line, then its `Correct:` fix. Detector: the self-check that prints it (C1 to C4), or `visual` when reading finds it. Source: the pilot run that wrote it, or `default` (a training-data habit).
## Pairs
### 1. Stagger on every row (Detector: C2; source: run-08)
Incorrect: `transition={{ delay: i * 0.05 }}` on all 50 rows of a table
Correct: 1 stagger of at most 6 items, `staggerChildren: 0.05`, never on table rows
### 2. Transition everything on a card (Detector: C1; source: runs 10, 15)
Incorrect: `className="rounded-xl border transition-all duration-300 hover:shadow-lg"`
Correct: `transition-colors` for a hover color; a lift is `transform` over a static shadow
### 3. Raw palette for a state (Detector: C3; source: runs 08, 15)
Incorrect: `<span className="text-green-600">Active</span>`
Correct: `<Badge variant="success-light">Active</Badge>`
### 4. Animated table rows (Detector: C1; source: runs 01, 08, 17)
Incorrect: `<motion.tr whileHover={{ backgroundColor: "var(--muted)" }}>`
Correct: `data-grid` rows, which already hover; never animate rows with motion/react
### 5. Hover scale on the default spring (Detector: C1; source: runs 01, 10)
Incorrect: `<motion.div whileHover={{ scale: 1.03 }}>` with no `transition`
Correct: CSS first: `transition-colors` for hover, `transition-transform active:scale-[0.98] motion-reduce:transition-none` for press
### 6. Entrance from scale 0 (Detector: C1; source: runs 10, 15)
Incorrect: `<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>`
Correct: `initial={{ opacity: 0, scale: 0.95 }}`: start at scale 0.95 or more and move 8px or less
### 7. Hand-built modal or toast (Detector: C4; source: run-15)
Incorrect: `<div className="fixed inset-0 z-50 bg-black/50">` as a modal
Correct: `Dialog`, `AlertDialog` for a destructive confirm, `sonner` for toasts; a palette: `<CommandDialog><Command><CommandInput /><CommandList>`, as `CommandDialog` holds no `Command`
### 8. Native select (Detector: C4; source: run-15)
Incorrect: `<select>` with `<option>` children
Correct: the shadcn `Select`, or `NativeSelect` when the native control is wanted
### 9. Hand-rolled table (Detector: C4; source: run-17)
Incorrect: `<table>` with mapped `<tr>` rows
Correct: `data-grid`: a `useTable({ features: dataGridFeatures })` instance passed as `table={table}`, columns typed `ColumnDef<DataGridFeatures, Order>[]`
### 10. Button inside a clickable wrapper (Detector: visual; source: run-02)
Incorrect: `<motion.button><Button>Save</Button></motion.button>`
Correct: the `Button` alone: 1 interactive element, and it already has a press state
### 11. Radix asChild on Base UI (Detector: C4; source: run-17, judge)
Incorrect: `<DialogTrigger asChild><Button>Open</Button></DialogTrigger>`
Correct: `<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>` on base-nova; a link: `<Button nativeButton={false} render={<Link href="/x" />}>`
### 12. Component defined inside render (Detector: visual; source: Haiku runs, judge)
Incorrect: `const Row = () => <div />` inside the page component
Correct: declare `Row` at module scope and pass it props, so React keeps its state
### 13. A panel inside a panel (Detector: visual; source: run-05)
Incorrect: `<FramePanel><Card>...</Card></FramePanel>`, or a Card inside a Card
Correct: FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame
### 14. Small badges (Detector: visual; source: runs 03, 04, 06, 07, 10, 13, 16, 18)
Incorrect: `<Badge variant="success-light" size="sm">Paid</Badge>`
Correct: `<Badge variant="success-light">Paid</Badge>` with `size` left off
### 15. A colored circle behind every KPI icon (Detector: visual; source: default)
Incorrect: `<div className="rounded-full bg-blue-100 p-2">` around each KPI icon
Correct: the number leads; at most 1 `text-muted-foreground` icon
### 16. Gradient text (Detector: visual; source: default)
Incorrect: `bg-linear-to-r from-violet-500 to-pink-500 bg-clip-text text-transparent` on a heading
Correct: `text-foreground` and `tracking-tight`; color carries meaning, not decoration
### 17. A dot or check for the current item (Detector: visual; source: default)
Incorrect: a dot or a `Check` icon beside the active nav item
Correct: `bg-muted` (`dark:bg-muted/50`) plus `aria-current`
### 18. framer-motion (Detector: C4; source: default)
Incorrect: `import { motion } from "framer-motion"`
Correct: `import { motion } from "motion/react"`
### 19. Clock or randomness in render (Detector: C4; source: default, near run-05)
Incorrect: `const [id] = useState(() => Math.random())`
Correct: `useId()` for ids; read the clock in an effect or an event handler
### 20. Placeholder data (Detector: visual; source: default)
Incorrect: "John Doe", "Acme Inc" and round figures like `$1,000`
Correct: specific, plausible data: "Mara Lind", `$1,284.50`, 37 open orders
## Your training data is out of date
| Old habit | In this stack |
| ----------------------------- | --------------------------------------------------------------- |
| `framer-motion` | `motion/react` |
| `<DataGrid data columns>` | `useTable({ features: dataGridFeatures })` plus `table={table}` |
| A fixed div modal | `Dialog` or `AlertDialog` |
| A hand-built toast | `sonner` |
| `w-5 h-5` | `size-5` |
| never `space-y-4` in new code | `flex flex-col gap-4` |
| `asChild` | `render` on base-nova |
+77
View File
@@ -0,0 +1,77 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the ReUI components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks and Motion Icons) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
`<name>` is the item name exactly as a ReUI tool returned it. The `@reui/` prefix is for ReUI items only:
- The shadcn/ui components ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. Install them from shadcn by bare name: `npx shadcn@latest add button --yes`.
- `badge` and `alert` exist in both registries: `@reui/badge` and `@reui/alert` are ReUI's components, bare `badge` and `alert` are shadcn's.
- In an item's `registryDependencies`, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both, so you never add them by hand.
- Templates are not registry items. Download a template from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs one.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the ReUI components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) need Ultimate.
- Templates are downloads from their page on reui.io, not installs: free ones need only a ReUI account, and each template's page says what the others need.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
If an install 404s, the name is not a ReUI item. Read the CLI's error message, which names the right install command when there is one, or look the name up with `get_install_command` or `search`.
+74
View File
@@ -0,0 +1,74 @@
# Color
Semantic tokens carry meaning; raw palette carries identity. Check every color in light and dark.
## Tokens
Surfaces, text and borders use your theme tokens (`background`, `foreground`, `card`, `muted`, `muted-foreground`, `primary`, `accent`, `destructive`, `border`, `ring`), so a theme change re-tints them. ReUI adds state tokens: `--success`, `--info` and `--warning` with their `-foreground`, `--destructive-foreground` (beside shadcn's `--destructive`) and `--invert` with `--invert-foreground` for inverted surfaces.
They are not part of shadcn's base theme. Installing `@reui/badge`, `@reui/alert` or `@reui/code-block` adds them to your global CSS; until one of those is installed, classes such as `bg-success` generate nothing. Check for `--success:` in your global CSS. If it is missing, add these values to `:root` and `.dark`, plus a `--color-<name>: var(--<name>)` line for each under `@theme inline`:
<!-- gen:tokens:start -->
| Token | `:root` | `.dark` |
| -------------------------- | -------------------------- | -------------------------- |
| `--destructive-foreground` | `var(--color-red-800)` | `var(--color-red-600)` |
| `--success` | `var(--color-emerald-500)` | `var(--color-emerald-500)` |
| `--success-foreground` | `var(--color-emerald-900)` | `var(--color-emerald-600)` |
| `--info` | `var(--color-violet-500)` | `var(--color-violet-500)` |
| `--info-foreground` | `var(--color-violet-900)` | `var(--color-violet-600)` |
| `--warning` | `var(--color-yellow-500)` | `var(--color-yellow-500)` |
| `--warning-foreground` | `var(--color-yellow-900)` | `var(--color-yellow-600)` |
| `--invert` | `var(--color-zinc-900)` | `var(--color-zinc-700)` |
| `--invert-foreground` | `var(--color-zinc-50)` | `var(--color-zinc-50)` |
<!-- gen:tokens:end -->
## Meaning vs identity
- Meaning (a state, emphasis, a surface, text) takes semantic tokens, through the Badge or Alert variant that already carries them.
- Identity (a chart series, a tag color, a team, an avatar) may use raw palette: ReUI's own lint keeps it on purpose. Always hand-pair it for dark, as in `text-violet-600 dark:text-violet-400`.
- **Incorrect:** `<span className="text-green-600">Active</span>` (raw palette for a state). **Correct:** `<Badge variant="success-light">Active</Badge>`.
## State recipes
- State badge: `<Badge variant="success-light">` from `@/components/reui/badge` (`@reui/badge`; shadcn's `components/ui/badge` has no `-light` variants or `size`), also `info-light`, `warning-light`, `destructive-light`, and `primary-light` for brand or pending. Neutral badge: `variant="outline"`. A solid `<Badge variant="success">` (white text) only when the user asks for a solid fill.
- Inline status text: `text-success-foreground dark:text-success`, the pair the ReUI Badge uses.
- Tinted surface: `border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success` (the Badge's `success-light` recipe).
- A message block: `<Alert variant="success">` from `@/components/reui/alert` (`@reui/alert`), also `info`, `warning` and `destructive`.
- Never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
## Contrast
<!-- gen:contrast:start -->
Light is `text-<tone>-foreground` on `background` and on the `bg-<tone>/10` tint; dark is `dark:text-<tone>` on `background` and on the `dark:bg-<tone>/15` tint. Ratios against the neutral theme, rounded down; body text needs 4.5:1.
| Tone | Light | Light on tint | Dark | Dark on tint |
| ------------- | ----- | ------------- | ---- | ------------ |
| `success` | 9.6 | 8.8 | 8.0 | 6.6 |
| `warning` | 8.6 | 8.1 | 10.3 | 8.1 |
| `info` | 11.0 | 9.7 | 4.4 | 3.9 |
| `destructive` | 8.3 | 7.0 | 6.8 | 5.7 |
Raw state color as light-mode text on `background`: `text-success` 2.4, `text-warning` 1.9, `text-info` 4.4.
<!-- gen:contrast:end -->
Info text in dark mode is under 4.5:1: where it must be read, write the words in `text-foreground` and color only the icon.
## Dark mode
- shadcn uses the class strategy: `@custom-variant dark (&:is(.dark *));` in your global CSS, and `.dark` on `<html>` turns it on.
- Tokens switch by themselves; raw palette needs its `dark:` pair.
- Check both themes before you hand back.
## Charts
Check your theme's `--chart-1` to `--chart-5` in both modes. The current shadcn themes keep the same chart colors in light and dark; some themes (reui.io's own among them) change hue. For a series whose identity must hold in both modes, use a palette var such as `var(--color-violet-500)`, as ReUI blocks do.
<!-- gen:charts:start -->
Same chart colors in light and dark: neutral, stone, zinc, mauve, olive, mist, taupe, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow. Changes hue between light and dark: reui.io's own theme.
<!-- gen:charts:end -->
+521
View File
@@ -0,0 +1,521 @@
# ReUI components
The ReUI components: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `color-picker`, `data-grid`, `date-selector`, `emoji-picker`, `event-calendar`, `file-type`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `signature-pad`, `sortable`, `stepper`, `time-picker`, `timeline`, `tree`. Examples and blocks are composed from these. `file-upload` is a ReUI component too, shipped as the `use-file-upload` hook rather than a primitive, so it is not in that list: install it as `@reui/use-file-upload`, which writes `hooks/use-file-upload.ts` (import `useFileUpload` from `@/hooks/use-file-upload`), call `get_component("file-upload")` for its API and `get_examples("file-upload")` for its free examples. There is no `@reui/file-upload`: the UI is a `c-file-upload-<n>` example, and one built on the hook installs it.
**Rule one: never guess a component's API.** Call **`get_component(name)`** for its inline `api` and share its `docsUrl` with the user, then **`get_examples(name)`** and copy a worked example. The contracts below are first-try orientation (required props, shape, the one gotcha); the `api` is the reference. A large `api` (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries: `sectionsOmitted` names what was dropped and the response's `next` hint pulls it back. `validate_usage` always checks the full API. No single block fits? Compose from components, 1 `get_examples` example each ([compose.md](./compose.md)).
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table with `useTable({ features: dataGridFeatures, ... })`. `dataGridFeatures` already bundles sorting, filtering, pagination, selection, expanding, pinning, resizing and faceting: no row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data: `manualPagination: true` and `rowCount: total` in `useTable`, `recordCount={total}`, and fetch with the table's `pagination` state (`DataGridPagination` changes `pageSize`).
- Columns are TanStack v9, `ColumnDef<DataGridFeatures, Order>[]`, never v8's `ColumnDef<Order>[]`; a sortable column's `header` renders `DataGridColumnHeader` (a string header cannot sort).
- Imports, each from `@/components/reui/data-grid/<file>`: `DataGrid`, `DataGridFeatures` and `dataGridFeatures` from `data-grid`, `DataGridColumnHeader` from `data-grid-column-header`, `DataGridTable` from `data-grid-table`, `DataGridPagination` from `data-grid-pagination`, `DataGridScrollArea` from `data-grid-scroll-area`; `useTable` and `ColumnDef` from `@tanstack/react-table`.
- States: `isLoading` renders skeleton rows from each column's `meta.skeleton` (a `ReactNode`: `<Skeleton className="h-4 w-20" />`); `emptyMessage` (a `ReactNode`) fills a grid with no rows: after a failed fetch, pass it the error with a Retry.
- Numeric column: `meta: { headerClassName: "text-end [&>div]:justify-end", cellClassName: "text-end tabular-nums" }`.
- Rows edited in place: `autoResetPageIndex: false` in `useTable`, or each write jumps back to page 1.
- In a Frame: `<FramePanel className="p-0">` around `DataGridScrollArea` and `DataGridTable`, plus `tableClassNames={{ base: "min-w-[720px]" }}` so on a phone the table scrolls inside the panel instead of squeezing.
```tsx
const columns = useMemo<ColumnDef<DataGridFeatures, Order>[]>(() => [
{ accessorKey: "total", header: ({ column }) => <DataGridColumnHeader title="Total" column={column} /> },
], [])
const table = useTable({ features: dataGridFeatures, data, columns })
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Never a raw `<table>` or hand-rolled pagination: the API covers sticky header, virtualization and row selection.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<div className="flex items-center justify-between">
<h3>{id}</h3>
<KanbanColumnHandle aria-label="Move column"><GripVertical /></KanbanColumnHandle>
</div>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks. `KanbanColumnHandle` is hidden until its column is hovered or it has keyboard focus, so keep the column title outside it.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()`: ids come from `createFilterIdFactory(seed)` seeded off `useId` (never minted in render), and `createFilterRule({ id })` takes one, `createFilterQuery()` none. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string, and `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches the current level and everything under it, `searchScope="global"` the whole tree from any level, and both annotate results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `virtualize` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels (paging, retry, `prefetch`). `CascaderFooter` pins commands below the list and `CascaderSubmenu` opens one as a side flyout. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/reui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window). `smooth` defaults to true: pass `smooth={!reduceMotion}` from motion/react's `useReducedMotion()`. Mark the active link `aria-current="location"` (track it with `onUpdate`); `"page"` is for route links.
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<FramePanel>Content</FramePanel>
<FrameFooter>Footer</FrameFooter>
</Frame>
```
**Gotcha:** the shell for data and tool-like surfaces. `spacing` (`xs` | `sm` | `default` | `lg`) sets panel padding and the gap between panels, so pick it instead of padding classes (the 1 exception: `<FramePanel className="p-0">` around a `data-grid`); `variant` is `default` | `inverse` | `ghost`; `stacked` joins panels with shared borders; `dense` removes the frame's own inset and gap (panels sit flush), and panel padding stays; `--frame-radius` is the one radius knob (panels derive `--frame-panel-radius`).
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light">Success</Badge>
<Badge variant="outline">Draft</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl` (keep the default), `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes: `<tone>-light` for a state, `outline` for a neutral label; an omitted `variant` is the solid primary fill.
## color-picker
**Required:** none (`<ColorPicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<ColorPicker value={color} onValueChange={setColor} alpha>
<ColorPickerTrigger />
<ColorPickerContent>
<ColorPickerPanel>
<ColorPickerArea />
<ColorPickerSlider channel="hue" />
<ColorPickerSlider channel="alpha" />
<ColorPickerFields />
</ColorPickerPanel>
</ColorPickerContent>
</ColorPicker>
```
**Gotcha:** the value is a CSS color string in `format` (`"hex"` default, `"rgb"`, `"hsl"`, `"hsb"` or `"oklch"`), `null` when empty, never an object. `onValueChange` fires on every drag move, so save in `onValueCommit`. Without `alpha` the value is always opaque. A bare `ColorPickerPanel` inside the root is an inline picker; `ColorPickerInput` replaces the trigger with a typable field.
## emoji-picker
**Required:** none; wire `onEmojiSelect` to insert a pick, or `value` + `onValueChange` to use it as a field.
**Shape:**
```tsx
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
<EmojiPickerTrigger />
<EmojiPickerContent>
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
```
**Gotcha:** the emoji data is fetched at runtime from a pinned jsDelivr path (`EMOJI_PICKER_DATA_URL`): under a strict Content Security Policy allow it in `connect-src`, self-host with `dataUrl`, or pass `data` (from `normalizeEmojibaseData`). Store the pick's `value` (the native emoji in its skin tone, or `:shortcode:` for a custom one). `multiple` makes the value a list, capped by `max`.
## file-type
**Required:** none; pass `type` (an extension, a family name or any string).
**Shape:**
```tsx
<FileType type="pdf" />
<FileType type={getFileType(file.name)} size="sm" aria-hidden="true" />
```
**Gotcha:** one `span` with `role="img"`, no children, no `"use client"` (fine in a Server Component). Resolve a file name, path, URL or MIME type with `getFileType`, never by splitting on the dot. `variant`: `outline` (default) | `solid` | `light` | `mono`; `size`: `mini` | `xs` | `sm` | `default` | `lg` | `xl` (18 to 56px); recolor with a `text-*` class. Pass `aria-hidden="true"` when the file name sits beside it.
## signature-pad
**Required:** `SignaturePad` > `SignaturePadArea`
**Shape:**
```tsx
<SignaturePad value={strokes} onValueChange={setStrokes}>
<SignaturePadArea>
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
```
**Gotcha:** the value is an array of strokes (`{ points: [x, y, size][], color? }`), not an image: store it as JSON, and export with `useSignaturePad()` or `apiRef` (`toSVG`, `toDataURL`, `toBlob`) or `SignaturePadSave` (`format`, `onSave`). `onValueChange` fires per finished stroke, never per pointer move. Size the area with a class (`h-40` by default); an overlay in it that is not a control needs `pointer-events-none`. Drawing needs a pointer, so offer a typed name where a signature is required.
## time-picker
**Required:** none (`<TimePicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<TimePicker value={time} onValueChange={setTime}>
<TimePickerTrigger />
<TimePickerContent>
<TimePickerColumns />
<TimePickerFooter>
<TimePickerNow />
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
```
**Gotcha:** the value is a 24-hour `"HH:mm"` string (`"HH:mm:ss"` with `granularity="second"`) or `null`, never a `Date`, whatever `hourCycle={12}` shows. `minuteStep`, `min`, `max` and `isTimeDisabled` narrow the options; keep `isTimeDisabled` stable (module scope or memoized). `requireConfirm` holds picks as a draft until OK. `TimePickerInput` replaces `TimePickerTrigger` for a typable field, never beside it.
## base vs radix - write for the project's base
Every component ships a `base` (Base UI) and a `radix` (Radix UI) build under 1 name, and the CLI installs the one the project uses. Read `components.json` `style`: the part before the first `-` is the base (`base-nova` is **Base UI**, `radix-nova` is **Radix UI**). Write against that base; the deltas mirror shadcn's:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
Installed files and `c-*` examples are already in your base: reuse their wiring. When an `api` or example shows the other base's shape, translate it (or `validate_usage` to confirm).
+62
View File
@@ -0,0 +1,62 @@
# Compose a screen
Read this before you build any screen or section, then write the router's 7-line plan in your reply (Surface, Archetype, Container family, Density, Regions, States, Motion) and build exactly that plan. Style it with the topics the screen touches: [layout.md](./layout.md) (house defaults), [color.md](./color.md), [motion.md](./motion.md).
If the brief names a layout or a motion (a left nav, 2 entrances), follow the brief and name the default you overrode. Several entrances run as 1 stagger: at most 6 items, 50ms apart, never on table rows or lists of 20 or more.
## Surface
- **Operational**: a screen people work in every day (dashboard, records, settings, admin tools). Frame-led, dense (`size="sm"` on controls that take it), no hero, no page-load motion.
- **Marketing**: a page a visitor reads once (landing, pricing, features). Card-led sections on the spine below, default density.
- **Flow**: a task people finish and leave (sign in, onboarding, checkout). 1 centered Card or a Dialog, 1 primary action per step.
## Archetypes: pick 1 per screen
- **Vertical rail settings** (3 to 6 sections): a section rail on the left, 1 content column, 1 section at a time; the rail turns horizontal below `md`.
- **Horizontal tabs** (5 to 8 views of 1 object): a full-width `Tabs` rail under the header that scrolls sideways when it overflows, 1 content column.
- **Frame stack**: stacked Frames, each 1 focused group of settings or records; a flat page split by `Separator` rows when it should feel lighter.
- **Table-backed management**: header, toolbar (search, `filters`), then a `data-grid` with row actions and pagination, all in 1 Frame.
- **Dashboard grid**: a KPI row, then 1 or 2 chart or list panels, then the table.
- **Board**: header and filters, then a `kanban` board; `KanbanBoard` stacks its columns below `sm`, and a wider board scrolls inside its own region.
- **Record detail**: header (title, status `Badge`, actions), a main column of sections, a side column of facts that stacks under it below `lg`.
- **Marketing spine**: optional `Badge` eyebrow, headline (the only loud element), 1-clause subhead, action row (1 primary `Button`, a second `variant="outline"`, a third a text link), specific proof beside it ("27 reviews"), media. Omit parts; never reorder them.
- **Auth card**: 1 centered `Card` at `max-w-sm`: title, fields, 1 full-width primary `Button`, a text link to the other flow.
- **Wizard dialog**: a `Dialog` holding a `stepper`, 1 step visible, Back (`variant="outline"`) and Next (primary) in its footer.
An app shell around any of them is the shadcn `Sidebar`, which turns into a `Sheet` below `md` by itself.
## Fill each region in this order
1. The ReUI component that owns the job: the router's Owners (`data-grid`, `filters`, `kanban`), `useFileUpload` from `@/hooks/use-file-upload` for file drop, or the one whose contract in [components.md](./components.md) matches.
2. A shadcn/ui component (`Tabs`, `Dialog`, `AlertDialog`, `Sheet`, `Empty`, `Skeleton`, `sonner`).
3. A free `c-*` example from `get_examples` that already composes it: install it and adapt.
4. Your own composition of those parts. Never a hand-rolled table, modal or tabs.
Import ReUI parts file by file: `data-grid`, `filters`, `cascader`, `event-calendar` and `gantt` are folders with no index file. `data-grid`: the import line in [components.md](./components.md). `filters`, each from `@/components/reui/filters/<file>`: `Filters` from `filters`, `createFilterQuery` and `flattenFilterConditions` (1 argument, the query) from `filters-query`, the types `FilterField` and `FilterQuery` from `filters-types`.
## Wiring that works
- `data-grid`: columns are `ColumnDef<DataGridFeatures, Order>[]`, a sortable header renders `DataGridColumnHeader`, and server paging needs `manualPagination: true` and `rowCount`.
- `filters`: apply each condition by its `operator`. Negate swaps a rule to its inverse (`is` and `is_not`, `contains` and `not_contains`, `is_any_of` and `is_none_of`, `empty` and `not_empty`) and sets `negated` only when there is none: honour both.
- A Base UI `Button` that navigates: `<Button nativeButton={false} render={<Link href="/x" />}>`.
- shadcn's `CommandDialog` has no `Command` inside, unlike older shadcn: wrap `CommandInput` and `CommandList` in one. `CommandItem` search matches its `value`: omit it (text is used) or use the label; ids in `onSelect`.
## Headers
A page or panel header puts the title (1 to 3 words) and a 1-clause description on the left and the actions on the right: 1 primary `Button`, the rest `variant="outline"`, row actions `variant="ghost"`.
## Responsive
- No horizontal page scroll at 390px: grids drop to 1 column, side columns stack, `min-w-0` on every shrinking parent.
- A KPI row is `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4`.
- Every header and toolbar action stays reachable at every width: a control hidden below a breakpoint moves into a menu shown only there.
- A table scrolls sideways inside its Frame, never the page (the `data-grid` contract in [components.md](./components.md)).
## States and motion
- Each data region owns its states: empty (shadcn `Empty` with a title, 1 line and the primary action), loading (`Skeleton` in the real layout; `data-grid` takes `isLoading`) and error (inline in the region, with Retry). Never 1 spinner for the whole page.
- Motion: none, or the 1 entrance per view; installed parts keep their own motion, and hover and press follow [motion.md](./motion.md). An Alert, field error, notice or data that appears after an action or a load is a reveal, not an entrance: fade it in with the reveal row.
## Remove first
Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments. Add a region only when the brief needs it.
+43
View File
@@ -0,0 +1,43 @@
# Icons (ReUI delta over shadcn)
Use the project's configured `iconLibrary`, put `data-icon="inline-start"` on an icon before the label inside `Button` (`data-icon="inline-end"` on one after it), put no sizing classes on icons inside components, and pass icons as components, not string keys. ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. The shadcn CLI installs each item's icons in the library named by `iconLibrary` in `components.json` - you swap nothing. When `iconLibrary` is not set, the CLI falls back to `lucide`, so the icons arrive as `lucide-react` imports. If that is not the project's library, set `iconLibrary` before installing, or change the **import source and component name** to your library, keeping the same icon-name semantics (each key below is an `iconLibrary` value):
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remixicon` -> `@remixicon/react`
- `hugeicons` -> icons from `@hugeicons/core-free-icons`, rendered through `HugeiconsIcon` from `@hugeicons/react`
Don't assume `lucide-react`: read `iconLibrary` from `components.json`, and when it is unset, check which icon package the project already uses.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled). Every icon installs from the default path; some also have a real hover animation (motion/react) on the animated path:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static, every icon
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated, only when the icon has installAnimated
```
The animated path answers for every icon, but for an icon without a real animation it installs the same static drawing, so only use it when the icon carries `installAnimated`.
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. On Ultimate, a Motion Icon on the primary action is the 1 hover flourish (see [motion.md](./motion.md)).
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a real hover animation; a concept with none comes back empty and `animatedNote` says so.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` only when the icon really animates - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (the icon's own page) and a `thumbnail` (its 4 styles side by side) - **show or share them** so the user can SEE the icon before installing, and look at the thumbnail yourself before trusting a name.
- Several icons can fit one concept. Before installing, read `get_icon`'s `whenNotToUse` (each entry names the icon to use instead), and use its `label` as the `aria-label` of an icon-only button.
- Icon names were revised in the icons v2 update. An old name (`save-2`, `abstract-18`) still answers in `get_icon`, `get_install_command`, `validate_usage` and `get_thumbnail` with the icon it became and `renamedFrom`, and still installs that same drawing; write the current name in new code. That includes names renamed because they misdescribed the drawing (`watch` installs a sand timer, `chevrons-down` points up), so look at the `thumbnail` before trusting an old name. `search_icons` answers an old name the same way, unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word, and `oldNameNotes` names the icon the old name installs. `search` ranks by current names only, so look an old name up with `get_icon`, never `search`. Only `tag` (it drew a money bag) no longer answers: the reply names the icons to use instead.
The `icon-stack` component composes multiple icons into a stacked display.
+56
View File
@@ -0,0 +1,56 @@
# Layout: house defaults, hierarchy, spacing, surfaces and states
## Point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
**The bar:** before you finish, ask: would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content? If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
## House defaults
<!-- gen:house-defaults:start -->
- **Spacing ladder**: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections, `gap-0.5` title to description. Deviate when: skip a level if you like, never invert them.
- **Panel title**: no size class on `FrameTitle` or `CardTitle`. Deviate when: never; `FrameTitle` ships `text-sm font-semibold`, `CardTitle` follows the installed style.
- **Meta text**: `text-xs`. Deviate when: never smaller, except inside illustrative mini-UI artwork.
- **Frame spacing**: `spacing="default"`, written out. Deviate when: `sm` in a sheet, dialog, drawer or popover, or for a dense data grid in a panel; `xs` or `lg` only with a stated reason.
- **Leading row icon**: `size-4`. Deviate when: never in a glyph column; `Spinner` ships `size-4`, so a loading swap keeps the width.
- **Icon inside a control**: no size class; the control sizes it per style and size. Deviate when: never pin `size-*` on an svg inside `Button`, `InputGroupButton`, `InputGroupAddon`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `SelectItem`, `CommandItem`, `Toggle`, `ToggleGroupItem`, `IconTile` or `MarkerIcon`.
- **Badge size**: `default` (leave `size` off). Deviate when: `lg` or `xl` only when the user asks; never `sm` or `xs`.
- **Neutral badge**: `variant="outline"`. Deviate when: never `secondary`, `default` or `primary-light` for an id, tag, version, count or Draft/Beta label; inline after the title, never `ml-auto`.
- **State badge**: `<tone>-light`: `success-light`, `info-light`, `warning-light`, `destructive-light`; `primary-light` for brand or pending. Deviate when: never the solid tone, `secondary` or `outline` for a state; 1 state badge per row.
- **Solid badge**: never unless asked; always name a variant (an omitted one is the solid primary fill). Deviate when: only when the user asks for a solid fill (`default`, `secondary`, `success`, `info`, `warning`, `destructive`, `focus`, `invert`).
- **Slot padding**: the primitive's own inset; set density with `Frame spacing=` or `Card size="sm"`. Deviate when: never a padding class on `CardHeader`, `CardContent`, `CardFooter`, `FrameHeader`, `FramePanel`, `FrameFooter`, `SheetHeader`, `SheetContent`, `SheetFooter`, `DialogHeader`, `DialogContent`, `DialogFooter`, `PopoverContent`, `DropdownMenuContent`, `TabsContent` or `AccordionContent`.
- **Inline separator**: `<span aria-hidden className="bg-muted-foreground/40 size-1 shrink-0 rounded-full" />`. Deviate when: never a typed `·` between rendered segments; a literal dot only inside 1 data string.
- **List rows**: dense, auto height; a row with no second line stays 1 tight line. Deviate when: never uniform height.
- **Current list item**: ghost hover fill (`bg-muted`, `dark:bg-muted/50`) plus `aria-current`. Deviate when: never a dot or a check mark.
<!-- gen:house-defaults:end -->
## Hierarchy
- 1 focal point per card or panel: the dominant metric or task, then its label, then detail. Borders, separators and surfaces make 2 to 3 bands, never equal weight everywhere.
- Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments.
## Spacing and copy
- Siblings take the parent's `gap-*`, never their own margins; a heading sits nearer its own content than the section above it. Never pad an operational surface like a marketing page.
- Titles 1 to 3 words, a description 1 clause (about 8 words), a context line 2 segments at most, labels and buttons 1 to 2 words, a toast title 4 words at most. Copy that wraps at desktop width is over budget: cut it, never shrink the type.
## Surfaces
- `Frame` for data and operational surfaces (tables, lists, tool panels); `Card` for bounded sections and marketing.
- Prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card.
- A `data-grid` inside a Frame is the one place for `<FramePanel className="p-0">`; elsewhere no padding on slots. This is the 1 exception to the Slot padding row above.
- 1 archetype per screen (operational ones and the marketing spine): [compose.md](./compose.md) lists each with its regions.
## Type and buttons
- A KPI value is `text-2xl font-semibold tracking-tight tabular-nums`.
- `size="sm"` on dense surfaces, `variant="outline"` for secondary actions, `ghost` for row actions; 1 primary per region, 1 variant per toolbar.
- A toolbar separator is `<Separator orientation="vertical" className="my-auto h-4" />`; at a 390px viewport a toolbar stays on 1 row.
## States and responsive
- Empty: a short message plus the primary action. Loading: a `Skeleton` in the real layout, never a centered spinner. Error: inline with a retry.
- No horizontal page scroll at 390px: reflow to 1 column instead of shrinking; `min-w-0` on the shrinking parent and `truncate` on single-line labels.
+80
View File
@@ -0,0 +1,80 @@
# Motion
## Installed motion: never retime it
<!-- gen:installed-motion:start -->
| Installed part | Duration |
| ------------------ | ----------------- |
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
<!-- gen:installed-motion:end -->
Never add or change `duration-*`, `ease-*`, `animate-*` or `transition-*` on installed parts (`components/ui`, `components/reui`); Button already presses with `active:not-aria-[haspopup]:translate-y-px`.
## House motion: your own code
<!-- gen:house-motion:start -->
| Use | Tailwind |
| ------------------------- | ------------------------------------------------------- |
| Hover, press, color | `transition-colors` (150ms, no duration or ease class) |
| Reveal or expand | `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` |
| Large surface (ceiling) | `duration-300` with the same curve |
| An overlay you build | `duration-100` with the same curve |
| Media hover zoom | `duration-500` with the same curve, `scale-105` at most |
| Width tied to the sidebar | `duration-200 ease-linear` |
<!-- gen:house-motion:end -->
- Press on a custom surface (not Button): `transition-transform active:scale-[0.98] motion-reduce:transition-none`.
- Hover color plus press on 1 element: `transition-[color,background-color,border-color,scale]` (`transition-colors` and `transition-transform` override each other; `scale-*` sets `scale`).
- motion/react takes the same values in seconds: `{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }`; hover `{ duration: 0.15 }`.
- Drag, reorder and layout use `const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }`. Define `SPRING` only when something drags, reorders or toggles layout.
## Hard rules
- Animate `transform` and `opacity`; color only for hover and press. Never `transition-all`; width, height, top or left only in the sidebar row.
- Never `animate-bounce` or a spring with bounce above 0. `ease-linear` only for progress, spinners, loops and the sidebar; never Tailwind's `ease-in`, `ease-out` or `ease-in-out`.
- `motion/react` only: never framer-motion, GSAP or react-spring.
- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its `Skeleton`) is a reveal, not an entrance: fade it in with the reveal row.
- Never add named `duration-*` utilities or redefine `--ease-out`.
- Loading: a `Skeleton` (it already pulses) in the real layout; a spinner only inside a busy button.
- Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its `installAnimated` command; never retime it.
## Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare `transition` (it also animates box-shadow and filter). (`reui/no-paint-animation`; 240px: judgement (no check))
- A hover lift is `transform` over a static shadow, or a shadow layer whose `opacity` fades; never a transitioned `hover:shadow-*`. (`reui/no-paint-animation`)
- CSS first: hover, press, reveals and ambient loops are Tailwind transitions, `tw-animate-css` or `@keyframes`. motion/react only for exit on unmount, drag, springs that follow input, `layout` and scroll-linked values. (judgement (no check))
- In motion/react, `x`, `y`, `scale`, `rotate`, `pathLength` and every SVG target run on the main thread: never `repeat: Infinity` on them (loop with CSS `@keyframes`), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element without `repeatDelay`, `repeatType: "mirror"` or `onUpdate`. (`reui/no-main-thread-loop`)
- SVG geometry (a rect's `width`, a circle's `r`) does not reflow the page but runs in motion's JS loop: one-shot only. (`reui/no-main-thread-loop`)
- Never set React state per frame (requestAnimationFrame, `useAnimationFrame`, `onUpdate`, scroll or pointermove listeners): bind styles to `useMotionValue`, `useTransform` or a CSS variable on the element. (`reui/no-frame-setstate`)
- A JS-driven loop (motion `repeat`, requestAnimationFrame, canvas) pauses offscreen with `useInView` or IntersectionObserver; `content-visibility: auto` never stops script, and a setInterval loop also pauses on `visibilitychange`. (`reui/loop-offscreen`)
- No permanent `will-change`: no `will-change-*` class or `willChange` style, except during a drag, removed when it ends. (`reui/will-change`)
- Stagger 50ms or less per step (`staggerChildren: 0.05`), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals use `whileInView` with `viewport={{ once: true }}`. (`reui/stagger-budget`; item count and tail: judgement (no check))
- A height reveal (`grid-template-rows`) starts only on a click or key press, runs 200ms or less, 1 at a time; never `initial={{ height: 0 }}`. (`reui/no-layout-transition`)
- The motion `layout` prop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through `@reui/sortable` or `@reui/kanban`. (judgement (no check))
- AnimatePresence `mode="wait"` only with an exit of 150ms or less; `initial={false}` around content present at first render. (`reui/stagger-budget`)
- Never hide the hero or the largest heading behind a motion/react `initial` at opacity 0 (hidden until JavaScript runs). (judgement (no check))
- At most 3 ambient loops per view: CSS `@keyframes` on transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (a `content-visibility: auto` wrapper with `contain-intrinsic-size: auto <h>` where nothing paints outside it, or IntersectionObserver toggling `animation-play-state`). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
## Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (`reui/reduced-motion`)
- The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (`reui/reduced-motion`)
- In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (`reui/reduced-motion`; half the viewport: judgement (no check))
- `<MotionConfig reducedMotion="user">` once at the app root (its default ignores the OS) AND a `motion-reduce:` path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (`reui/reduced-motion`)
- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a `transform` string keep animating, so a loop or those keys branch on `useReducedMotion()` and rest on the final value. (`reui/reduced-motion`)
- `useReducedMotion()` is null on the server: never let it pick markup (JSX, className, `initial`) or reach `animate` under `initial={false}`; branch only animate, transition, `while*` or exit values. (`reui/reduced-motion-ssr`)
- No SMIL (`<animate>`, `<animateMotion>`, `<animateTransform>`); canvas and requestAnimationFrame loops draw 1 resting frame under `prefers-reduced-motion: reduce`. (`reui/no-smil`, `reui/reduced-motion`)
- Smooth scroll only under `motion-safe:scroll-smooth`. A focus ring appears at once: never `transition-all`, `transition-shadow` or a bare `transition` on it. (`reui/reduced-motion`, `reui/focus-transition`)
- Loaders and typing dots keep their opacity pulse; every decorative loop gets `motion-reduce:animate-none`, never an inline `style={{ animation }}` (it beats the class). (`reui/reduced-motion`)
- Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (`reui/pausable-autoplay`)
- Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))
+57
View File
@@ -0,0 +1,57 @@
# Quality gates (security, accessibility, scroll, self-checks)
This is the **done gate**: before you call any ReUI work finished, call `get_audit_checklist` and pass every item below (plus the bar and the states in [layout.md](./layout.md)). Then run the self-checks, typecheck and lint, compare with [anti-patterns.md](./anti-patterns.md), and hand back in the 5 lines below.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance. Hover on a display-only card: make the card a link to its detail, or give it no hover.
- `aria-current="page"` on the current route link, `"location"` on the current in-page section link.
- Destructive confirms use `AlertDialog`, not `Dialog`, and cannot be dismissed while pending.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship their keyboard behavior; never re-implement it.
- Announce async UI: loading and error messages use `role="status"` or `aria-live`.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
## Self-checks
After every UI edit, run these with ripgrep on `<files>`: each file you wrote or changed, by path, never `components/ui` or `components/reui`. A clean check prints nothing; a hit prints `file:line:code`. The last filter drops comment lines.
```sh
# C1 banned motion: transition-all, bare or paint transitions, layout keys, bounce, overshoot, scale 0, hover and tap gestures, will-change
rg -nH 'transition-all|transition(-property)?: *[^;]*\b(all|width|height|top|left|margin|padding|box-shadow|filter)\b|[\x27\x60" ]transition( +(duration|ease|delay|hover|focus|active|group|motion)|[\x27\x60"])|transition-shadow|transition-\[[^\]]*\b(width|height|top|left|right|bottom|inset|margin|padding|filter|radius|background-position|background-size|all)\b|animate-bounce|will-change|willChange|bounce: *(0?\.0*[1-9]|[1-9])|[\x27"](backIn|backOut|backInOut|anticipate)[\x27"]|(cubic-bezier\(|ease: *\[)([^,]*,|([^,]*,){3})[ _]*(-|1\.[0-9]*[1-9]|[2-9])|scale: *0 *[,}]|\bscale-0\b|while(Hover|Tap|Focus)=|(initial|animate|exit)=\{\{[^}]*\b(width|height|maxHeight|top|left|margin|padding|boxShadow|filter|backdropFilter|borderRadius)[A-Za-z]*:' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 off-scale motion: a duration off 0, 100, 150, 200 and 300 (duration-500 only on a media hover zoom), ease-in or ease-out, a stagger step above 0.05
rg -nH 'duration-([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})\b|duration-\[|\b([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})ms\b|(^|[^0-9.])(0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)|1\.[0-9]+)s\b|duration: *([1-9]\b|0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)\b)|\bease-(in|out|in-out)\b|(staggerChildren: *|stagger\( *)(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *[^,}]*\b(i|index|idx)\b *\* *(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *(0?\.0[6-9]|0?\.[1-9]|[1-9])[0-9.]* *\* *\b(i|index|idx)\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 read by hand: a duration or delay that is not a number, every index delay (allowed: 0.05 or less on the first 6 items, as Math.min(i, 5) * 0.05, never on table rows)
rg -nH '([dD]uration|[dD]elay): *[^0-9 .]|delay: *[0-9.]+ *\*' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C3 state color: raw palette on a line that names a state
rg -nH '\b(text|bg|border|ring|fill|stroke)-(red|rose|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet)-[0-9]{2,3}\b' <files> | rg -i ':[0-9]+:.*\b(success|paid|active|completed?|done|approved|online|healthy|live|error|failed|critical|overdue|declined|rejected|cancell?ed|blocked|expired|warning|pending|degraded|due|info|processing|running|scheduled|queued)\b' | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 structure: a raw table, select or fixed overlay, framer-motion, Math.random() or Date.now()
rg -nH '<table\b|<select\b|fixed inset-0|from ["\x27]framer-motion|Math\.random\(|Date\.now\(' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 on a base-* style only (skip on radix-*): Radix asChild
rg -nH '\basChild\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
```
Fix every hit, run again, stop after the second run and list what is left with file and line. A C4 hit stays only when a comment in the file says why. These also stay, named on hand-back line 2: C1 on the motion.md sidebar row or on will-change set in a drag handler and removed when it ends; C2 on a media hover zoom; `Math.min(i, 5) * 0.05`.
No `rg` (a missing one also prints nothing): run each first pattern with your own search tool, skipping comment lines. No shell: read each changed file for what each `#` line names.
## Hand-back
End your reply with these 5 lines:
1. Installed: each item with its `previewUrl`.
2. Motion: each animated element and its [motion.md](./motion.md) table row, or "none".
3. States: covered, or which are missing.
4. Checks: C1 to C4, screenshots or "not visually verified", the audit checklist, typecheck, lint; each one run, or "not run" and why.
5. Deviations: any rule broken or check hit left, with file:line and why.
+44
View File
@@ -0,0 +1,44 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the ReUI components with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (a full dashboard or settings page). Pro or Ultimate license at install. Its surfaces, text and borders use your theme tokens; its category accents (chart series, tags, avatars) are fixed palette colors paired for light and dark.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`), plus a hover-animated variant (`@reui/icons/animated/<style>/<name>`) only for icons that carry `installAnimated`. Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
ReUI items install through the shadcn CLI as `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a ReUI tool returned it (`search`, a getter, `get_install_command`). The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## ReUI items vs shadcn/ui components
- **ReUI items** (the components, `c-*` examples, blocks, Motion Icons) install as `@reui/<name>`.
- **shadcn/ui components** that ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. They install from shadcn by bare name: `npx shadcn@latest add button --yes`.
- **`badge` and `alert` exist in both registries.** `@reui/badge` and `@reui/alert` are ReUI's components (the extra variants in [components.md](./components.md)); bare `badge` and `alert` are shadcn's.
- **In an item's `registryDependencies`**, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both for you.
- **Templates are not registry items.** Download one from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs a template.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks take surfaces, text and borders from your theme tokens, so a theme change re-tints them; category accents stay fixed by design.
## Free vs premium
- **Free, no license key:** the ReUI components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate) and Motion Icons (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
- **Templates** are downloads, not installs: free ones need only a ReUI account, and each template's page on reui.io says what the others need.
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+37
View File
@@ -0,0 +1,37 @@
# Theming: styles, radius, density and sizes
## Read your style
`components.json` `style` is `<base>-<style>`: the part after the first `-` names the style (`base-nova` is nova on Base UI). The style is baked into `components/ui` and `components/reui` at install, so the installed files hold your real values: read your installed `button.tsx` (usually `components/ui/button.tsx`) for heights, radius and padding before you build anything that sits beside a control.
## The style ladder
<!-- gen:style-ladder:start -->
| Style | Button `sm` / default / `lg` | Button radius | Card radius, shadow | Card spacing | Button text |
| ----- | ---------------------------- | -------------- | ---------------------------------------------------- | ------------ | -------------------- |
| vega | `h-8` / `h-9` / `h-10` | `rounded-md` | `rounded-xl`, `shadow-xs` | 6, sm 4 | `text-sm` |
| nova | `h-7` / `h-8` / `h-9` | `rounded-lg` | `rounded-xl` | 4, sm 3 | `text-sm` |
| maia | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-2xl` | 6, sm 4 | `text-sm` |
| lyra | `h-7` / `h-8` / `h-9` | `rounded-none` | `rounded-none` | 4, sm 3 | `text-xs` |
| mira | `h-6` / `h-7` / `h-8` | `rounded-md` | `rounded-lg` | 4, sm 3 | `text-xs/relaxed` |
| luma | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-4xl`, `shadow-md` | 6, sm 4 | `text-sm` |
| sera | `h-9` / `h-10` / `h-11` | `rounded-none` | square, `shadow-sm` | 8, sm 5 | `text-xs`, uppercase |
| rhea | `h-7` / `h-8` / `h-9` | `rounded-2xl` | `rounded-[min(var(--radius-4xl),24px)]`, `shadow-sm` | 5, sm 4 | `text-sm` |
<!-- gen:style-ladder:end -->
Card spacing is the `--card-spacing` step (`--spacing(n)`) a Card pads with; `Card size="sm"` uses the sm step. The default Button text weight is `font-medium` (sera: `font-semibold`).
## What the style owns
- Never set what the style owns on its parts: radius (except `rounded-full`), height, horizontal padding, shadow, ring or font size. Pick `size` and `variant` instead.
- A panel is a `Card` or a `Frame`, never a div with `rounded-*`, `border` and `bg-card`; set density with `Card size="sm"` or Frame `spacing`.
- A bespoke element beside a control (a chip, a custom trigger) takes its height and radius from that control's row above.
- A focusable control you build copies the focus classes of your installed `input.tsx`.
- A variant before a class: `className` is for layout (placement, width, gap). `size-*` when width equals height, `cn()` for conditional classes (never a template literal), a scale step before an arbitrary value (`w-60`, not `w-[240px]`), no inline `style` for color or spacing, and no `z-index` on dialogs, sheets or popovers.
- Change the look through the theme CSS variables in `:root` and `.dark` (`--radius`, `--primary` and the rest) or with a style switch (a new `style` in `components.json`, which means reinstalling the components; ask the user first). Never edit installed files to restyle them.
## Components with no per-style values
`event-calendar`, `gantt`, `icon-stack`, `kanban`, `rating`, `scrollspy`, `sortable` and `timeline` install the same file in every style. Their look comes from your theme tokens and the parts you put inside them (a `Card` in a kanban column follows the style), so never copy another style's radius onto them.
+66
View File
@@ -0,0 +1,66 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## When to reach for ReUI vs plain shadcn
- A full page or section (dashboard, billing, auth, pricing, settings): `compose_page` first (step 1).
- A data table with sorting, filtering, pagination or virtualization: the `data-grid` component, never a hand-rolled `<table>`.
- A drag-and-drop board: the `kanban` component.
- Column filters, a date range, a tree, a stepper and the like: the matching ReUI component.
- A single generic control shadcn already has (Button, Dialog, Select): plain shadcn.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent)` FIRST (`sections`, 1 to 10, caps how many sections, default 8), before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (a ReUI component), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid" })` (on a free account use `type: "example"`).
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Pick with the guidance, not the title.** Look-alikes are common (dialog, sheet and drawer; dozens of data-grid examples and app shells). Before installing, read the item's guidance: `get_component` returns `whenToUse` and `whenNotToUse`, `get_example` and `get_block` return `bestFor` (what sets the item apart from its siblings), `agentNotes` and `whenNotToUse`, and a block adds `categoryGuidance` (its category's key note), and `get_examples` rows carry `bestFor`. Each `whenNotToUse` entry names the item to use instead and its `type` (`component` -> `get_component`, `example` -> `get_example`, `block` -> `get_block`, `block-category` "<group>/<category>" -> `search({ query, type: "block", category: "<category>" })`); when the user's situation matches its `when`, switch. Apply every `agentNotes` line before you ship: it names the real identifiers to replace or wire (sample data, stubbed actions, required props). An item with no guidance yet has none of these keys; fall back to its description and thumbnail.
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**Show the preview.** List every item with its `previewUrl` (a live preview page). Where a result carries `thumbnail` (blocks, `c-*` examples and icons), show the image as markdown linked to `previewUrl`, so the user sees it before you install.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. Write against your project's base (see [components.md](./components.md)). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to the active theme: semantic tokens for anything that carries meaning, raw palette only for categorical identity and always with its `dark:` pair (see [color.md](./color.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, motion that leaves installed components alone, and mobile-first responsiveness (see [layout.md](./layout.md) and [motion.md](./motion.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+74
View File
@@ -0,0 +1,74 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; Pro unlocks premium blocks, Ultimate adds Motion Icons, and either removes the daily request limit by default. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (`sections` is a number, 1 to 10: the most sections to return, default 8); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Only some icons have a real hover animation: `animated: true` returns only those, and a concept with none comes back empty with an `animatedNote`. Each icon carries a `thumbnail` of its 4 styles; look at it before trusting a name. An old icon name still resolves (`renamedFrom` names it), unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word and `oldNameNotes` names the icon the old name installs. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale. With no `topic` it returns the core workflow, the topic index and the installer; `topic` (for example `motion`) returns that one file in full. Compare its `version` with the one stamped atop your local `SKILL.md`.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Commands (MCP prompts)
In Claude Code the ReUI MCP adds these prompts; other agents show MCP prompts under their own names, or not at all. Plain language runs the same loop.
- `/mcp__reui__build <what>`: compose a page, section or feature (plan, install, read the API, adapt, audit).
- `/mcp__reui__add <item>`: find, install and wire in 1 component, example, block or icon.
- `/mcp__reui__fix [target]`: fix ReUI usage (undocumented props, a base or radix mismatch, missing states, accessibility, scroll).
- `/mcp__reui__improve [target]`: refine existing ReUI UI (hierarchy, density, states, responsive, motion).
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- Icon results and `get_icon` include `animated: true` and `installAnimated` only when the icon has a real hover animation (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`). Use the strings given; never build an animated path by hand, it installs a static copy for every other icon.
- Blocks, `c-*` examples and icons carry `thumbnail`, an absolute URL to a hosted preview image (light theme; blocks 1080x720, examples 900x600, icons 640x200 with the 4 styles side by side). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Guidance is getter-only, never on search rows: `get_component` adds `whenToUse`, `whenNotToUse` and `agentNotes`; `get_example` and `get_block` add `bestFor`, `agentNotes` and `whenNotToUse`, and `get_block` also `categoryGuidance`; `get_examples` rows add `bestFor`. Answers carry the key lines; the item page's `.md` twin has every line. Each `whenNotToUse` entry is `{ when, instead, type }`: fetch `instead` with the getter its `type` names.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
-1
View File
@@ -42,7 +42,6 @@ package-lock.json
data
*.exe
**/*.test.ts
**/test/**
**/*.spec.ts
coverage
.coverage
+33
View File
@@ -0,0 +1,33 @@
# Database
DATABASE_URL=sqlite:data/app.db
# Auth JWT (access tokens for apps)
JWT_SECRET=dev-secret-change-me
JWT_TTL_HOURS=1
REFRESH_TTL_DAYS=14
ISSUER=https://auth.shnt.top
# OIDC IdP (defaults to ISSUER). Optional PEM PKCS8 RSA key — otherwise auto-generated in DB.
# OIDC_ISSUER=https://auth.shnt.top
# OIDC_RSA_PRIVATE_KEY=
# Bootstrap admin (created on first start if DB empty)
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=admin
ADMIN_NAME=Admin
# Allowed return_to hosts (comma-separated), e.g. .shnt.top or full origins
RETURN_TO_ALLOWLIST=.shnt.top,localhost,private,http://localhost:5173,http://localhost:5174,http://localhost:5176
# ReUI PRO (apps/web/components.json → @reui Authorization)
# Ключ: https://reui.io/docs/license-setup — класть в apps/web/.env.local (gitignored)
REUI_LICENSE_KEY=
# Local app URLs for SSO Open (apps/web/.env.local)
# VITE_VPS_APP_URL=http://localhost:5173
# VITE_CDN_APP_URL=http://localhost:5176
# VITE_RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173,http://localhost:5176
# Server
SERVER_PORT=8080
STATIC_DIR=
LOG_LEVEL=info
-34
View File
@@ -1,34 +0,0 @@
name: setup-pnpm
description: Node 22 + кэш pnpm store / corepack / node_modules / .turbo по хешу lockfile
outputs:
cache-hit:
description: Был ли точный хит кэша (передайте в PNPM_CACHE_HIT для scripts/ci/pnpm-ci.sh)
value: ${{ steps.pnpm-cache.outputs.cache-hit }}
runs:
using: composite
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
shell: sh
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
shell: bash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
-2
View File
@@ -129,8 +129,6 @@ jobs:
env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
# provenance + SBOM как attestations образа (только при push в registry)
ATTEST: "true"
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
-108
View File
@@ -1,108 +0,0 @@
name: MCP isolated portal acceptance preview
'on':
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed full commit SHA on the selected ref; includes all accepted source changes
required: true
type: string
permissions:
contents: read
packages: write
jobs:
portal-preview:
if: ${{ gitea.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 40
env:
COMPONENT: portal
EXPECTED_SHA: ${{ inputs.expected_sha }}
DISPATCH_SHA: ${{ gitea.sha }}
DISPATCH_EVENT: ${{ gitea.event_name }}
REPOSITORY: ${{ gitea.repository }}
RUN_ID: ${{ gitea.run_id }}
RUN_ATTEMPT: ${{ gitea.run_attempt }}
steps:
- name: Checkout exact dispatch commit
uses: actions/checkout@v4
with:
ref: ${{ gitea.sha }}
persist-credentials: false
- name: Use existing Linux Node22 build toolchain
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Verify source and isolated preview destination
shell: bash
run: |
set -euo pipefail
test "$DISPATCH_EVENT" = workflow_dispatch
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
git diff --exit-code
git diff --cached --exit-code
[[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
[[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
mkdir -p .ci/acceptance-evidence
node <<'NODE'
const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
NODE
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to existing Gitea registry
uses: docker/login-action@v3
with:
registry: git.shx.one
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Build and push isolated acceptance image
id: build
uses: docker/build-push-action@v5
with:
context: .
file: deploy/docker/authportal/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
build-args: |-
VERSION=${{ env.PREVIEW_TAG }}
GIT_SHA=${{ gitea.sha }}
cache-from: type=registry,ref=${{ env.PREVIEW_CACHE }}
cache-to: type=registry,ref=${{ env.PREVIEW_CACHE }},mode=max
provenance: mode=min
sbom: true
labels: |-
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
io.mmapp.acceptance-only=true
- name: Record exact digest and build metadata
shell: bash
env:
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
BUILD_METADATA: ${{ steps.build.outputs.metadata }}
run: |
set -euo pipefail
node <<'NODE'
const fs=require('node:fs'),crypto=require('node:crypto');
const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
NODE
- name: Retain source and image binding artifacts
uses: actions/upload-artifact@v3
with:
name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
path: .ci/acceptance-evidence/*.json
if-no-files-found: error
retention-days: 14
-60
View File
@@ -1,60 +0,0 @@
# Ночной нагрузочный smoke: образ под лимитами compose + autocannon + бюджеты p99/RPS/RSS.
name: nightly-perf
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
load:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build image
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
run: |
set -euxo pipefail
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:load' authportal
- name: Run container with production limits
run: |
set -euxo pipefail
docker volume create load-data
docker run --rm -v load-data:/data --user root --entrypoint sh authportal:load -c 'chown 1000:1000 /data'
docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 \
--read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data \
-e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \
-e [email protected] -e ISSUER=http://127.0.0.1:8080 \
-e RATE_LIMIT_MAX=1000000 authportal:load
for i in $(seq 1 40); do
docker exec authportal-load wget -q -O /dev/null http://127.0.0.1:8080/ready && break
sleep 0.5
done
# Раннер сам работает в контейнере: нагрузку гоним из соседнего контейнера
# в сетевом namespace приложения (127.0.0.1:8080 — это приложение).
- name: Load test (autocannon в namespace приложения)
run: |
set -euxo pipefail
docker run --rm -i --network container:authportal-load \
-e LOAD_PASSWORD=load-admin-pass-1 -e LOAD_DURATION_S=15 \
node:22-alpine sh -c 'mkdir /w && cd /w && npm i --silent --no-audit --no-fund [email protected] && cat > load.mjs && node load.mjs http://127.0.0.1:8080' \
< scripts/perf/load.mjs
- name: RSS после нагрузки
run: |
set -euxo pipefail
rss=$(docker stats --no-stream --format '{{.MemUsage}}' authportal-load \
| sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' \
| awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}')
echo "RSS: ${rss} MiB (budget 200)"
[ "$rss" -le 200 ]
- name: Container logs on failure
if: failure()
run: docker logs --tail 100 authportal-load || true
+74 -29
View File
@@ -110,7 +110,7 @@ jobs:
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
.gitea/workflows/*|.gitea/actions/*|scripts/*)
.gitea/workflows/*|scripts/*)
full_pipeline=true
;;
.cursor/*|.claude/*|.codegraph/*|.agents/*)
@@ -160,19 +160,38 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- id: setup
uses: ./.gitea/actions/setup-pnpm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, lint, typecheck, build
env:
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm --filter web lint
pnpm exec turbo run typecheck --filter=web
pnpm exec turbo run build --filter=web
pnpm --filter web test
node scripts/ci/web-budget.mjs apps/web/dist/assets
api:
needs: [changes]
@@ -181,16 +200,37 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- id: setup
uses: ./.gitea/actions/setup-pnpm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, lint, test, typecheck, build
env:
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec turbo run lint --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm exec turbo run test:coverage --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm exec turbo run test --filter=@authportal/api
pnpm exec turbo run typecheck --filter=@authportal/api
pnpm exec turbo run build --filter=@authportal/api
@@ -202,13 +242,33 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- id: setup
uses: ./.gitea/actions/setup-pnpm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Lint commit messages
env:
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
@@ -216,9 +276,7 @@ jobs:
docker-check:
needs: [changes]
# Образ собирается из api/web/deploy: проверяем и на push в main (до публикации),
# чтобы сломанный Dockerfile не обнаруживался уже после создания тега релиза.
if: needs.changes.outputs.docker == 'true' || needs.changes.outputs.api == 'true' || needs.changes.outputs.web == 'true'
if: inputs.is_pull_request && needs.changes.outputs.docker == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -252,16 +310,3 @@ jobs:
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
export CACHE_REF_NODE="git.shx.one/${owner_lc}/authportal-buildcache:node-buildcache"
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
- name: build image + smoke test (ресурсы, graceful stop, сохранность данных)
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
run: |
set -euxo pipefail
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:smoke' authportal
bash "${{ github.workspace }}/scripts/ci/smoke-image.sh" authportal:smoke
- name: trivy (HIGH/CRITICAL; информативно, пайплайн не блокирует)
continue-on-error: true
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.58.1 image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 0 authportal:smoke
-2
View File
@@ -36,5 +36,3 @@ version.txt
.release-version
CHANGELOG.md
.claude/settings.local.json
coverage
+83
View File
@@ -0,0 +1,83 @@
---
name: reui
description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind. Use for any UI work in a project with @reui in components.json or a components/reui folder, and in any shadcn project that asks for data grids, kanban boards, dashboards or full pages: building or restyling pages, forms and tables; colors, theme tokens and dark mode; spacing, typography and layout; animation and hover motion; installing ReUI components, examples, Pro blocks or Motion Icons. Works with the ReUI MCP."
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
---
> **ReUI skill version `68f0e6fa91`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer in the mode it was installed with to update this skill: `get_agent_skill` -> `install.recommended` from the project root, with `REUI_GLOBAL=1` before `node` when this file is `~/.claude/skills/reui/SKILL.md` or `~/.agents/skills/reui/SKILL.md` (a global install). Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI
ReUI is an existing design system on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here.
It ships free components, free `c-*` examples, Pro blocks and Ultimate Motion Icons through the shadcn CLI; the ReUI MCP finds them and serves their APIs.
## Order of authority
1. The installed style (`components.json` `style`, baked into `components/ui` and `components/reui`): it owns radius, height, padding, shadow and overlay motion.
2. The installed component's variants and props (`size`, `variant`, `spacing`).
3. These rules and the topic files.
4. Other design skills, then your taste: where they disagree with 1 to 3, ReUI wins. Never redefine `--ease-out` or any Tailwind built-in.
## Before you write UI code, write this plan in your reply
1. Surface: operational, marketing or flow.
2. Archetype: 1 from [compose.md](./rules/compose.md).
3. Container family: Frame-led, Card-led, or Cards for the KPIs and a Frame for the table.
4. Density: dense (`size="sm"` on controls that take it) or default.
5. Regions: top to bottom, each with the item that fills it.
6. States: empty, loading and error for each data region.
7. Motion: none, or the 1 entrance.
Then build exactly that plan; if you deviate, say where.
## The loop
1. **Find**: `compose_page` for a full page, else `search` with `type` and `component` hints.
2. **Install**: `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a tool returned it (shadcn/ui parts install by bare name: `npx shadcn@latest add button --yes`).
3. **Read the API on your base**: `components.json` style `base-nova` is Base UI, `radix-nova` is Radix. Call `get_component([...componentsUsed])` once, then install 1 `get_examples` example and read its files.
4. **Adapt**: change data, copy and props; keep the structure and classes.
5. **Check**, after every UI edit, follow-ups included: `validate_usage` on any name you did not read, the self-checks C1 to C4 in [quality.md](./rules/quality.md), [anti-patterns.md](./rules/anti-patterns.md), `get_audit_checklist`, typecheck, lint. Hand back in the 5 lines quality.md gives.
Show every item's `previewUrl` whenever you list, recommend or install it.
## Rules that always apply
- **Owners**: tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` (destructive confirms `AlertDialog`), toasts `sonner`; never hand-roll.
- **Color**: semantic tokens for meaning; state colors through Badge or Alert variants (`@reui/badge` and `@reui/alert`, not shadcn's); raw palette only for categorical identity, always with its `dark:` pair.
- **Contrast**: never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
- **Shape**: no `rounded-*` except `rounded-full`, and no `shadow-*`, `h-*` or `px-*` on Button, Input, SelectTrigger, Card or Frame; pick `size` and `variant`. A panel is a Card or Frame, never a div with `rounded-lg border`.
- **Spacing**: `gap-*` on the parent, not `space-y-*`; `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections; title to description `gap-0.5`.
- **Type**: `text-sm` body, `text-xs` meta (controls keep their style's size), `font-medium` or `font-semibold`, headings `tracking-tight`, numbers `tabular-nums`; nothing below `text-xs` except illustrative mini-UI.
- **Motion**: installed components own their motion. Your own code uses `transition-colors` for hover and press, `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` to reveal, `duration-300` with the same curve at most (media hover zoom: 500), and `SPRING` (`{ type: "spring", visualDuration: 0.25, bounce: 0 }`) for drag and reorder. At most 1 entrance animation per view (a stagger counts as 1). Animate only `transform` and `opacity`, CSS before motion/react, never set state per frame; `<MotionConfig reducedMotion="user">` once at the root plus `motion-reduce:` on CSS, where reduced motion means a calm fade, not nothing.
- **Surfaces**: prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card. Icons come from 1 library, the `iconLibrary` in `components.json` (on Ultimate, 1 Motion Icon on the primary action is the exception).
- **States**: each data region has empty (1 action), loading (a `Skeleton` in the real layout) and error (Retry) states; light and dark work.
- **Never invent**: never write a prop, variant or `@reui/` name you did not read in an `api`, an installed file or a tool result.
## Topics
Always read `compose` before building a screen, and `quality` plus `anti-patterns` at the Check step. Besides those, read only the topic files the task touches, at most 4 (for example `color`, `motion`, `layout` and `components`).
Installed skill: open the file. MCP only: call `get_agent_skill` with the topic, or fetch `https://mcp.reui.io/skill/reui/<file>`.
| Topic | File | Read when |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| `compose` | [rules/compose.md](./rules/compose.md) | before building any screen or section |
| `workflow` | [rules/workflow.md](./rules/workflow.md) | `compose_page`, search hints, installing |
| `adapting` | [rules/adapting.md](./rules/adapting.md) | changing an installed block or example |
| `components` | [rules/components.md](./rules/components.md) | `data-grid`, `filters`, `kanban` and the rest |
| `color` | [rules/color.md](./rules/color.md) | any color, status, chart or dark mode choice |
| `theming` | [rules/theming.md](./rules/theming.md) | the styles, radius, density and sizes |
| `motion` | [rules/motion.md](./rules/motion.md) | any transition, animation or motion/react |
| `layout` | [rules/layout.md](./rules/layout.md) | house defaults, hierarchy, surfaces, states |
| `icons` | [rules/icons.md](./rules/icons.md) | icon imports, Motion Icons |
| `quality` | [rules/quality.md](./rules/quality.md) | the done gate: self-checks, a11y, hand-back |
| `anti-patterns` | [rules/anti-patterns.md](./rules/anti-patterns.md) | the Check step: drift pairs, stale habits |
| `registry` | [rules/registry.md](./rules/registry.md) | free vs Pro vs Ultimate, base vs radix |
| `cli` | [rules/cli.md](./rules/cli.md) | `components.json`, license key, install flags |
| `tools` | [tools.md](./tools.md) | MCP result shapes, errors, quota, commands |
## Plans
Free covers components, `c-*` examples, this skill and the MCP (a ReUI account and a daily allowance). Pro adds blocks. Ultimate adds Motion Icons. A plan miss is a normal answer with `locked: true`; keep building with what the plan covers.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "ReUI"
short_description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind."
default_prompt: "Use ReUI to build this UI with installed components and the ReUI motion and color rules."
+40
View File
@@ -0,0 +1,40 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- Never read `window`, `localStorage`, `location`, `Date.now()` or `Math.random()` in render or in a `useState` initializer; read them in an effect.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something, and the layout must still hold with long names, empty values and crowded data.
@@ -0,0 +1,117 @@
# Anti-patterns
Read this at the Check step, after the self-checks in [quality.md](./quality.md). Each entry: an `Incorrect:` line, then its `Correct:` fix. Detector: the self-check that prints it (C1 to C4), or `visual` when reading finds it. Source: the pilot run that wrote it, or `default` (a training-data habit).
## Pairs
### 1. Stagger on every row (Detector: C2; source: run-08)
Incorrect: `transition={{ delay: i * 0.05 }}` on all 50 rows of a table
Correct: 1 stagger of at most 6 items, `staggerChildren: 0.05`, never on table rows
### 2. Transition everything on a card (Detector: C1; source: runs 10, 15)
Incorrect: `className="rounded-xl border transition-all duration-300 hover:shadow-lg"`
Correct: `transition-colors` for a hover color; a lift is `transform` over a static shadow
### 3. Raw palette for a state (Detector: C3; source: runs 08, 15)
Incorrect: `<span className="text-green-600">Active</span>`
Correct: `<Badge variant="success-light">Active</Badge>`
### 4. Animated table rows (Detector: C1; source: runs 01, 08, 17)
Incorrect: `<motion.tr whileHover={{ backgroundColor: "var(--muted)" }}>`
Correct: `data-grid` rows, which already hover; never animate rows with motion/react
### 5. Hover scale on the default spring (Detector: C1; source: runs 01, 10)
Incorrect: `<motion.div whileHover={{ scale: 1.03 }}>` with no `transition`
Correct: CSS first: `transition-colors` for hover, `transition-transform active:scale-[0.98] motion-reduce:transition-none` for press
### 6. Entrance from scale 0 (Detector: C1; source: runs 10, 15)
Incorrect: `<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>`
Correct: `initial={{ opacity: 0, scale: 0.95 }}`: start at scale 0.95 or more and move 8px or less
### 7. Hand-built modal or toast (Detector: C4; source: run-15)
Incorrect: `<div className="fixed inset-0 z-50 bg-black/50">` as a modal
Correct: `Dialog`, `AlertDialog` for a destructive confirm, `sonner` for toasts; a palette: `<CommandDialog><Command><CommandInput /><CommandList>`, as `CommandDialog` holds no `Command`
### 8. Native select (Detector: C4; source: run-15)
Incorrect: `<select>` with `<option>` children
Correct: the shadcn `Select`, or `NativeSelect` when the native control is wanted
### 9. Hand-rolled table (Detector: C4; source: run-17)
Incorrect: `<table>` with mapped `<tr>` rows
Correct: `data-grid`: a `useTable({ features: dataGridFeatures })` instance passed as `table={table}`, columns typed `ColumnDef<DataGridFeatures, Order>[]`
### 10. Button inside a clickable wrapper (Detector: visual; source: run-02)
Incorrect: `<motion.button><Button>Save</Button></motion.button>`
Correct: the `Button` alone: 1 interactive element, and it already has a press state
### 11. Radix asChild on Base UI (Detector: C4; source: run-17, judge)
Incorrect: `<DialogTrigger asChild><Button>Open</Button></DialogTrigger>`
Correct: `<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>` on base-nova; a link: `<Button nativeButton={false} render={<Link href="/x" />}>`
### 12. Component defined inside render (Detector: visual; source: Haiku runs, judge)
Incorrect: `const Row = () => <div />` inside the page component
Correct: declare `Row` at module scope and pass it props, so React keeps its state
### 13. A panel inside a panel (Detector: visual; source: run-05)
Incorrect: `<FramePanel><Card>...</Card></FramePanel>`, or a Card inside a Card
Correct: FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame
### 14. Small badges (Detector: visual; source: runs 03, 04, 06, 07, 10, 13, 16, 18)
Incorrect: `<Badge variant="success-light" size="sm">Paid</Badge>`
Correct: `<Badge variant="success-light">Paid</Badge>` with `size` left off
### 15. A colored circle behind every KPI icon (Detector: visual; source: default)
Incorrect: `<div className="rounded-full bg-blue-100 p-2">` around each KPI icon
Correct: the number leads; at most 1 `text-muted-foreground` icon
### 16. Gradient text (Detector: visual; source: default)
Incorrect: `bg-linear-to-r from-violet-500 to-pink-500 bg-clip-text text-transparent` on a heading
Correct: `text-foreground` and `tracking-tight`; color carries meaning, not decoration
### 17. A dot or check for the current item (Detector: visual; source: default)
Incorrect: a dot or a `Check` icon beside the active nav item
Correct: `bg-muted` (`dark:bg-muted/50`) plus `aria-current`
### 18. framer-motion (Detector: C4; source: default)
Incorrect: `import { motion } from "framer-motion"`
Correct: `import { motion } from "motion/react"`
### 19. Clock or randomness in render (Detector: C4; source: default, near run-05)
Incorrect: `const [id] = useState(() => Math.random())`
Correct: `useId()` for ids; read the clock in an effect or an event handler
### 20. Placeholder data (Detector: visual; source: default)
Incorrect: "John Doe", "Acme Inc" and round figures like `$1,000`
Correct: specific, plausible data: "Mara Lind", `$1,284.50`, 37 open orders
## Your training data is out of date
| Old habit | In this stack |
| ----------------------------- | --------------------------------------------------------------- |
| `framer-motion` | `motion/react` |
| `<DataGrid data columns>` | `useTable({ features: dataGridFeatures })` plus `table={table}` |
| A fixed div modal | `Dialog` or `AlertDialog` |
| A hand-built toast | `sonner` |
| `w-5 h-5` | `size-5` |
| never `space-y-4` in new code | `flex flex-col gap-4` |
| `asChild` | `render` on base-nova |
+77
View File
@@ -0,0 +1,77 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the ReUI components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks and Motion Icons) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
`<name>` is the item name exactly as a ReUI tool returned it. The `@reui/` prefix is for ReUI items only:
- The shadcn/ui components ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. Install them from shadcn by bare name: `npx shadcn@latest add button --yes`.
- `badge` and `alert` exist in both registries: `@reui/badge` and `@reui/alert` are ReUI's components, bare `badge` and `alert` are shadcn's.
- In an item's `registryDependencies`, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both, so you never add them by hand.
- Templates are not registry items. Download a template from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs one.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the ReUI components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) need Ultimate.
- Templates are downloads from their page on reui.io, not installs: free ones need only a ReUI account, and each template's page says what the others need.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
If an install 404s, the name is not a ReUI item. Read the CLI's error message, which names the right install command when there is one, or look the name up with `get_install_command` or `search`.
+74
View File
@@ -0,0 +1,74 @@
# Color
Semantic tokens carry meaning; raw palette carries identity. Check every color in light and dark.
## Tokens
Surfaces, text and borders use your theme tokens (`background`, `foreground`, `card`, `muted`, `muted-foreground`, `primary`, `accent`, `destructive`, `border`, `ring`), so a theme change re-tints them. ReUI adds state tokens: `--success`, `--info` and `--warning` with their `-foreground`, `--destructive-foreground` (beside shadcn's `--destructive`) and `--invert` with `--invert-foreground` for inverted surfaces.
They are not part of shadcn's base theme. Installing `@reui/badge`, `@reui/alert` or `@reui/code-block` adds them to your global CSS; until one of those is installed, classes such as `bg-success` generate nothing. Check for `--success:` in your global CSS. If it is missing, add these values to `:root` and `.dark`, plus a `--color-<name>: var(--<name>)` line for each under `@theme inline`:
<!-- gen:tokens:start -->
| Token | `:root` | `.dark` |
| -------------------------- | -------------------------- | -------------------------- |
| `--destructive-foreground` | `var(--color-red-800)` | `var(--color-red-600)` |
| `--success` | `var(--color-emerald-500)` | `var(--color-emerald-500)` |
| `--success-foreground` | `var(--color-emerald-900)` | `var(--color-emerald-600)` |
| `--info` | `var(--color-violet-500)` | `var(--color-violet-500)` |
| `--info-foreground` | `var(--color-violet-900)` | `var(--color-violet-600)` |
| `--warning` | `var(--color-yellow-500)` | `var(--color-yellow-500)` |
| `--warning-foreground` | `var(--color-yellow-900)` | `var(--color-yellow-600)` |
| `--invert` | `var(--color-zinc-900)` | `var(--color-zinc-700)` |
| `--invert-foreground` | `var(--color-zinc-50)` | `var(--color-zinc-50)` |
<!-- gen:tokens:end -->
## Meaning vs identity
- Meaning (a state, emphasis, a surface, text) takes semantic tokens, through the Badge or Alert variant that already carries them.
- Identity (a chart series, a tag color, a team, an avatar) may use raw palette: ReUI's own lint keeps it on purpose. Always hand-pair it for dark, as in `text-violet-600 dark:text-violet-400`.
- **Incorrect:** `<span className="text-green-600">Active</span>` (raw palette for a state). **Correct:** `<Badge variant="success-light">Active</Badge>`.
## State recipes
- State badge: `<Badge variant="success-light">` from `@/components/reui/badge` (`@reui/badge`; shadcn's `components/ui/badge` has no `-light` variants or `size`), also `info-light`, `warning-light`, `destructive-light`, and `primary-light` for brand or pending. Neutral badge: `variant="outline"`. A solid `<Badge variant="success">` (white text) only when the user asks for a solid fill.
- Inline status text: `text-success-foreground dark:text-success`, the pair the ReUI Badge uses.
- Tinted surface: `border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success` (the Badge's `success-light` recipe).
- A message block: `<Alert variant="success">` from `@/components/reui/alert` (`@reui/alert`), also `info`, `warning` and `destructive`.
- Never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
## Contrast
<!-- gen:contrast:start -->
Light is `text-<tone>-foreground` on `background` and on the `bg-<tone>/10` tint; dark is `dark:text-<tone>` on `background` and on the `dark:bg-<tone>/15` tint. Ratios against the neutral theme, rounded down; body text needs 4.5:1.
| Tone | Light | Light on tint | Dark | Dark on tint |
| ------------- | ----- | ------------- | ---- | ------------ |
| `success` | 9.6 | 8.8 | 8.0 | 6.6 |
| `warning` | 8.6 | 8.1 | 10.3 | 8.1 |
| `info` | 11.0 | 9.7 | 4.4 | 3.9 |
| `destructive` | 8.3 | 7.0 | 6.8 | 5.7 |
Raw state color as light-mode text on `background`: `text-success` 2.4, `text-warning` 1.9, `text-info` 4.4.
<!-- gen:contrast:end -->
Info text in dark mode is under 4.5:1: where it must be read, write the words in `text-foreground` and color only the icon.
## Dark mode
- shadcn uses the class strategy: `@custom-variant dark (&:is(.dark *));` in your global CSS, and `.dark` on `<html>` turns it on.
- Tokens switch by themselves; raw palette needs its `dark:` pair.
- Check both themes before you hand back.
## Charts
Check your theme's `--chart-1` to `--chart-5` in both modes. The current shadcn themes keep the same chart colors in light and dark; some themes (reui.io's own among them) change hue. For a series whose identity must hold in both modes, use a palette var such as `var(--color-violet-500)`, as ReUI blocks do.
<!-- gen:charts:start -->
Same chart colors in light and dark: neutral, stone, zinc, mauve, olive, mist, taupe, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow. Changes hue between light and dark: reui.io's own theme.
<!-- gen:charts:end -->
+521
View File
@@ -0,0 +1,521 @@
# ReUI components
The ReUI components: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `color-picker`, `data-grid`, `date-selector`, `emoji-picker`, `event-calendar`, `file-type`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `signature-pad`, `sortable`, `stepper`, `time-picker`, `timeline`, `tree`. Examples and blocks are composed from these. `file-upload` is a ReUI component too, shipped as the `use-file-upload` hook rather than a primitive, so it is not in that list: install it as `@reui/use-file-upload`, which writes `hooks/use-file-upload.ts` (import `useFileUpload` from `@/hooks/use-file-upload`), call `get_component("file-upload")` for its API and `get_examples("file-upload")` for its free examples. There is no `@reui/file-upload`: the UI is a `c-file-upload-<n>` example, and one built on the hook installs it.
**Rule one: never guess a component's API.** Call **`get_component(name)`** for its inline `api` and share its `docsUrl` with the user, then **`get_examples(name)`** and copy a worked example. The contracts below are first-try orientation (required props, shape, the one gotcha); the `api` is the reference. A large `api` (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries: `sectionsOmitted` names what was dropped and the response's `next` hint pulls it back. `validate_usage` always checks the full API. No single block fits? Compose from components, 1 `get_examples` example each ([compose.md](./compose.md)).
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table with `useTable({ features: dataGridFeatures, ... })`. `dataGridFeatures` already bundles sorting, filtering, pagination, selection, expanding, pinning, resizing and faceting: no row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data: `manualPagination: true` and `rowCount: total` in `useTable`, `recordCount={total}`, and fetch with the table's `pagination` state (`DataGridPagination` changes `pageSize`).
- Columns are TanStack v9, `ColumnDef<DataGridFeatures, Order>[]`, never v8's `ColumnDef<Order>[]`; a sortable column's `header` renders `DataGridColumnHeader` (a string header cannot sort).
- Imports, each from `@/components/reui/data-grid/<file>`: `DataGrid`, `DataGridFeatures` and `dataGridFeatures` from `data-grid`, `DataGridColumnHeader` from `data-grid-column-header`, `DataGridTable` from `data-grid-table`, `DataGridPagination` from `data-grid-pagination`, `DataGridScrollArea` from `data-grid-scroll-area`; `useTable` and `ColumnDef` from `@tanstack/react-table`.
- States: `isLoading` renders skeleton rows from each column's `meta.skeleton` (a `ReactNode`: `<Skeleton className="h-4 w-20" />`); `emptyMessage` (a `ReactNode`) fills a grid with no rows: after a failed fetch, pass it the error with a Retry.
- Numeric column: `meta: { headerClassName: "text-end [&>div]:justify-end", cellClassName: "text-end tabular-nums" }`.
- Rows edited in place: `autoResetPageIndex: false` in `useTable`, or each write jumps back to page 1.
- In a Frame: `<FramePanel className="p-0">` around `DataGridScrollArea` and `DataGridTable`, plus `tableClassNames={{ base: "min-w-[720px]" }}` so on a phone the table scrolls inside the panel instead of squeezing.
```tsx
const columns = useMemo<ColumnDef<DataGridFeatures, Order>[]>(() => [
{ accessorKey: "total", header: ({ column }) => <DataGridColumnHeader title="Total" column={column} /> },
], [])
const table = useTable({ features: dataGridFeatures, data, columns })
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Never a raw `<table>` or hand-rolled pagination: the API covers sticky header, virtualization and row selection.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<div className="flex items-center justify-between">
<h3>{id}</h3>
<KanbanColumnHandle aria-label="Move column"><GripVertical /></KanbanColumnHandle>
</div>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks. `KanbanColumnHandle` is hidden until its column is hovered or it has keyboard focus, so keep the column title outside it.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()`: ids come from `createFilterIdFactory(seed)` seeded off `useId` (never minted in render), and `createFilterRule({ id })` takes one, `createFilterQuery()` none. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string, and `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches the current level and everything under it, `searchScope="global"` the whole tree from any level, and both annotate results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `virtualize` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels (paging, retry, `prefetch`). `CascaderFooter` pins commands below the list and `CascaderSubmenu` opens one as a side flyout. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/reui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window). `smooth` defaults to true: pass `smooth={!reduceMotion}` from motion/react's `useReducedMotion()`. Mark the active link `aria-current="location"` (track it with `onUpdate`); `"page"` is for route links.
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<FramePanel>Content</FramePanel>
<FrameFooter>Footer</FrameFooter>
</Frame>
```
**Gotcha:** the shell for data and tool-like surfaces. `spacing` (`xs` | `sm` | `default` | `lg`) sets panel padding and the gap between panels, so pick it instead of padding classes (the 1 exception: `<FramePanel className="p-0">` around a `data-grid`); `variant` is `default` | `inverse` | `ghost`; `stacked` joins panels with shared borders; `dense` removes the frame's own inset and gap (panels sit flush), and panel padding stays; `--frame-radius` is the one radius knob (panels derive `--frame-panel-radius`).
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light">Success</Badge>
<Badge variant="outline">Draft</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl` (keep the default), `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes: `<tone>-light` for a state, `outline` for a neutral label; an omitted `variant` is the solid primary fill.
## color-picker
**Required:** none (`<ColorPicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<ColorPicker value={color} onValueChange={setColor} alpha>
<ColorPickerTrigger />
<ColorPickerContent>
<ColorPickerPanel>
<ColorPickerArea />
<ColorPickerSlider channel="hue" />
<ColorPickerSlider channel="alpha" />
<ColorPickerFields />
</ColorPickerPanel>
</ColorPickerContent>
</ColorPicker>
```
**Gotcha:** the value is a CSS color string in `format` (`"hex"` default, `"rgb"`, `"hsl"`, `"hsb"` or `"oklch"`), `null` when empty, never an object. `onValueChange` fires on every drag move, so save in `onValueCommit`. Without `alpha` the value is always opaque. A bare `ColorPickerPanel` inside the root is an inline picker; `ColorPickerInput` replaces the trigger with a typable field.
## emoji-picker
**Required:** none; wire `onEmojiSelect` to insert a pick, or `value` + `onValueChange` to use it as a field.
**Shape:**
```tsx
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
<EmojiPickerTrigger />
<EmojiPickerContent>
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
```
**Gotcha:** the emoji data is fetched at runtime from a pinned jsDelivr path (`EMOJI_PICKER_DATA_URL`): under a strict Content Security Policy allow it in `connect-src`, self-host with `dataUrl`, or pass `data` (from `normalizeEmojibaseData`). Store the pick's `value` (the native emoji in its skin tone, or `:shortcode:` for a custom one). `multiple` makes the value a list, capped by `max`.
## file-type
**Required:** none; pass `type` (an extension, a family name or any string).
**Shape:**
```tsx
<FileType type="pdf" />
<FileType type={getFileType(file.name)} size="sm" aria-hidden="true" />
```
**Gotcha:** one `span` with `role="img"`, no children, no `"use client"` (fine in a Server Component). Resolve a file name, path, URL or MIME type with `getFileType`, never by splitting on the dot. `variant`: `outline` (default) | `solid` | `light` | `mono`; `size`: `mini` | `xs` | `sm` | `default` | `lg` | `xl` (18 to 56px); recolor with a `text-*` class. Pass `aria-hidden="true"` when the file name sits beside it.
## signature-pad
**Required:** `SignaturePad` > `SignaturePadArea`
**Shape:**
```tsx
<SignaturePad value={strokes} onValueChange={setStrokes}>
<SignaturePadArea>
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
```
**Gotcha:** the value is an array of strokes (`{ points: [x, y, size][], color? }`), not an image: store it as JSON, and export with `useSignaturePad()` or `apiRef` (`toSVG`, `toDataURL`, `toBlob`) or `SignaturePadSave` (`format`, `onSave`). `onValueChange` fires per finished stroke, never per pointer move. Size the area with a class (`h-40` by default); an overlay in it that is not a control needs `pointer-events-none`. Drawing needs a pointer, so offer a typed name where a signature is required.
## time-picker
**Required:** none (`<TimePicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<TimePicker value={time} onValueChange={setTime}>
<TimePickerTrigger />
<TimePickerContent>
<TimePickerColumns />
<TimePickerFooter>
<TimePickerNow />
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
```
**Gotcha:** the value is a 24-hour `"HH:mm"` string (`"HH:mm:ss"` with `granularity="second"`) or `null`, never a `Date`, whatever `hourCycle={12}` shows. `minuteStep`, `min`, `max` and `isTimeDisabled` narrow the options; keep `isTimeDisabled` stable (module scope or memoized). `requireConfirm` holds picks as a draft until OK. `TimePickerInput` replaces `TimePickerTrigger` for a typable field, never beside it.
## base vs radix - write for the project's base
Every component ships a `base` (Base UI) and a `radix` (Radix UI) build under 1 name, and the CLI installs the one the project uses. Read `components.json` `style`: the part before the first `-` is the base (`base-nova` is **Base UI**, `radix-nova` is **Radix UI**). Write against that base; the deltas mirror shadcn's:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
Installed files and `c-*` examples are already in your base: reuse their wiring. When an `api` or example shows the other base's shape, translate it (or `validate_usage` to confirm).
+62
View File
@@ -0,0 +1,62 @@
# Compose a screen
Read this before you build any screen or section, then write the router's 7-line plan in your reply (Surface, Archetype, Container family, Density, Regions, States, Motion) and build exactly that plan. Style it with the topics the screen touches: [layout.md](./layout.md) (house defaults), [color.md](./color.md), [motion.md](./motion.md).
If the brief names a layout or a motion (a left nav, 2 entrances), follow the brief and name the default you overrode. Several entrances run as 1 stagger: at most 6 items, 50ms apart, never on table rows or lists of 20 or more.
## Surface
- **Operational**: a screen people work in every day (dashboard, records, settings, admin tools). Frame-led, dense (`size="sm"` on controls that take it), no hero, no page-load motion.
- **Marketing**: a page a visitor reads once (landing, pricing, features). Card-led sections on the spine below, default density.
- **Flow**: a task people finish and leave (sign in, onboarding, checkout). 1 centered Card or a Dialog, 1 primary action per step.
## Archetypes: pick 1 per screen
- **Vertical rail settings** (3 to 6 sections): a section rail on the left, 1 content column, 1 section at a time; the rail turns horizontal below `md`.
- **Horizontal tabs** (5 to 8 views of 1 object): a full-width `Tabs` rail under the header that scrolls sideways when it overflows, 1 content column.
- **Frame stack**: stacked Frames, each 1 focused group of settings or records; a flat page split by `Separator` rows when it should feel lighter.
- **Table-backed management**: header, toolbar (search, `filters`), then a `data-grid` with row actions and pagination, all in 1 Frame.
- **Dashboard grid**: a KPI row, then 1 or 2 chart or list panels, then the table.
- **Board**: header and filters, then a `kanban` board; `KanbanBoard` stacks its columns below `sm`, and a wider board scrolls inside its own region.
- **Record detail**: header (title, status `Badge`, actions), a main column of sections, a side column of facts that stacks under it below `lg`.
- **Marketing spine**: optional `Badge` eyebrow, headline (the only loud element), 1-clause subhead, action row (1 primary `Button`, a second `variant="outline"`, a third a text link), specific proof beside it ("27 reviews"), media. Omit parts; never reorder them.
- **Auth card**: 1 centered `Card` at `max-w-sm`: title, fields, 1 full-width primary `Button`, a text link to the other flow.
- **Wizard dialog**: a `Dialog` holding a `stepper`, 1 step visible, Back (`variant="outline"`) and Next (primary) in its footer.
An app shell around any of them is the shadcn `Sidebar`, which turns into a `Sheet` below `md` by itself.
## Fill each region in this order
1. The ReUI component that owns the job: the router's Owners (`data-grid`, `filters`, `kanban`), `useFileUpload` from `@/hooks/use-file-upload` for file drop, or the one whose contract in [components.md](./components.md) matches.
2. A shadcn/ui component (`Tabs`, `Dialog`, `AlertDialog`, `Sheet`, `Empty`, `Skeleton`, `sonner`).
3. A free `c-*` example from `get_examples` that already composes it: install it and adapt.
4. Your own composition of those parts. Never a hand-rolled table, modal or tabs.
Import ReUI parts file by file: `data-grid`, `filters`, `cascader`, `event-calendar` and `gantt` are folders with no index file. `data-grid`: the import line in [components.md](./components.md). `filters`, each from `@/components/reui/filters/<file>`: `Filters` from `filters`, `createFilterQuery` and `flattenFilterConditions` (1 argument, the query) from `filters-query`, the types `FilterField` and `FilterQuery` from `filters-types`.
## Wiring that works
- `data-grid`: columns are `ColumnDef<DataGridFeatures, Order>[]`, a sortable header renders `DataGridColumnHeader`, and server paging needs `manualPagination: true` and `rowCount`.
- `filters`: apply each condition by its `operator`. Negate swaps a rule to its inverse (`is` and `is_not`, `contains` and `not_contains`, `is_any_of` and `is_none_of`, `empty` and `not_empty`) and sets `negated` only when there is none: honour both.
- A Base UI `Button` that navigates: `<Button nativeButton={false} render={<Link href="/x" />}>`.
- shadcn's `CommandDialog` has no `Command` inside, unlike older shadcn: wrap `CommandInput` and `CommandList` in one. `CommandItem` search matches its `value`: omit it (text is used) or use the label; ids in `onSelect`.
## Headers
A page or panel header puts the title (1 to 3 words) and a 1-clause description on the left and the actions on the right: 1 primary `Button`, the rest `variant="outline"`, row actions `variant="ghost"`.
## Responsive
- No horizontal page scroll at 390px: grids drop to 1 column, side columns stack, `min-w-0` on every shrinking parent.
- A KPI row is `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4`.
- Every header and toolbar action stays reachable at every width: a control hidden below a breakpoint moves into a menu shown only there.
- A table scrolls sideways inside its Frame, never the page (the `data-grid` contract in [components.md](./components.md)).
## States and motion
- Each data region owns its states: empty (shadcn `Empty` with a title, 1 line and the primary action), loading (`Skeleton` in the real layout; `data-grid` takes `isLoading`) and error (inline in the region, with Retry). Never 1 spinner for the whole page.
- Motion: none, or the 1 entrance per view; installed parts keep their own motion, and hover and press follow [motion.md](./motion.md). An Alert, field error, notice or data that appears after an action or a load is a reveal, not an entrance: fade it in with the reveal row.
## Remove first
Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments. Add a region only when the brief needs it.
+43
View File
@@ -0,0 +1,43 @@
# Icons (ReUI delta over shadcn)
Use the project's configured `iconLibrary`, put `data-icon="inline-start"` on an icon before the label inside `Button` (`data-icon="inline-end"` on one after it), put no sizing classes on icons inside components, and pass icons as components, not string keys. ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. The shadcn CLI installs each item's icons in the library named by `iconLibrary` in `components.json` - you swap nothing. When `iconLibrary` is not set, the CLI falls back to `lucide`, so the icons arrive as `lucide-react` imports. If that is not the project's library, set `iconLibrary` before installing, or change the **import source and component name** to your library, keeping the same icon-name semantics (each key below is an `iconLibrary` value):
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remixicon` -> `@remixicon/react`
- `hugeicons` -> icons from `@hugeicons/core-free-icons`, rendered through `HugeiconsIcon` from `@hugeicons/react`
Don't assume `lucide-react`: read `iconLibrary` from `components.json`, and when it is unset, check which icon package the project already uses.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled). Every icon installs from the default path; some also have a real hover animation (motion/react) on the animated path:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static, every icon
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated, only when the icon has installAnimated
```
The animated path answers for every icon, but for an icon without a real animation it installs the same static drawing, so only use it when the icon carries `installAnimated`.
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. On Ultimate, a Motion Icon on the primary action is the 1 hover flourish (see [motion.md](./motion.md)).
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a real hover animation; a concept with none comes back empty and `animatedNote` says so.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` only when the icon really animates - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (the icon's own page) and a `thumbnail` (its 4 styles side by side) - **show or share them** so the user can SEE the icon before installing, and look at the thumbnail yourself before trusting a name.
- Several icons can fit one concept. Before installing, read `get_icon`'s `whenNotToUse` (each entry names the icon to use instead), and use its `label` as the `aria-label` of an icon-only button.
- Icon names were revised in the icons v2 update. An old name (`save-2`, `abstract-18`) still answers in `get_icon`, `get_install_command`, `validate_usage` and `get_thumbnail` with the icon it became and `renamedFrom`, and still installs that same drawing; write the current name in new code. That includes names renamed because they misdescribed the drawing (`watch` installs a sand timer, `chevrons-down` points up), so look at the `thumbnail` before trusting an old name. `search_icons` answers an old name the same way, unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word, and `oldNameNotes` names the icon the old name installs. `search` ranks by current names only, so look an old name up with `get_icon`, never `search`. Only `tag` (it drew a money bag) no longer answers: the reply names the icons to use instead.
The `icon-stack` component composes multiple icons into a stacked display.
+56
View File
@@ -0,0 +1,56 @@
# Layout: house defaults, hierarchy, spacing, surfaces and states
## Point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
**The bar:** before you finish, ask: would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content? If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
## House defaults
<!-- gen:house-defaults:start -->
- **Spacing ladder**: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections, `gap-0.5` title to description. Deviate when: skip a level if you like, never invert them.
- **Panel title**: no size class on `FrameTitle` or `CardTitle`. Deviate when: never; `FrameTitle` ships `text-sm font-semibold`, `CardTitle` follows the installed style.
- **Meta text**: `text-xs`. Deviate when: never smaller, except inside illustrative mini-UI artwork.
- **Frame spacing**: `spacing="default"`, written out. Deviate when: `sm` in a sheet, dialog, drawer or popover, or for a dense data grid in a panel; `xs` or `lg` only with a stated reason.
- **Leading row icon**: `size-4`. Deviate when: never in a glyph column; `Spinner` ships `size-4`, so a loading swap keeps the width.
- **Icon inside a control**: no size class; the control sizes it per style and size. Deviate when: never pin `size-*` on an svg inside `Button`, `InputGroupButton`, `InputGroupAddon`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `SelectItem`, `CommandItem`, `Toggle`, `ToggleGroupItem`, `IconTile` or `MarkerIcon`.
- **Badge size**: `default` (leave `size` off). Deviate when: `lg` or `xl` only when the user asks; never `sm` or `xs`.
- **Neutral badge**: `variant="outline"`. Deviate when: never `secondary`, `default` or `primary-light` for an id, tag, version, count or Draft/Beta label; inline after the title, never `ml-auto`.
- **State badge**: `<tone>-light`: `success-light`, `info-light`, `warning-light`, `destructive-light`; `primary-light` for brand or pending. Deviate when: never the solid tone, `secondary` or `outline` for a state; 1 state badge per row.
- **Solid badge**: never unless asked; always name a variant (an omitted one is the solid primary fill). Deviate when: only when the user asks for a solid fill (`default`, `secondary`, `success`, `info`, `warning`, `destructive`, `focus`, `invert`).
- **Slot padding**: the primitive's own inset; set density with `Frame spacing=` or `Card size="sm"`. Deviate when: never a padding class on `CardHeader`, `CardContent`, `CardFooter`, `FrameHeader`, `FramePanel`, `FrameFooter`, `SheetHeader`, `SheetContent`, `SheetFooter`, `DialogHeader`, `DialogContent`, `DialogFooter`, `PopoverContent`, `DropdownMenuContent`, `TabsContent` or `AccordionContent`.
- **Inline separator**: `<span aria-hidden className="bg-muted-foreground/40 size-1 shrink-0 rounded-full" />`. Deviate when: never a typed `·` between rendered segments; a literal dot only inside 1 data string.
- **List rows**: dense, auto height; a row with no second line stays 1 tight line. Deviate when: never uniform height.
- **Current list item**: ghost hover fill (`bg-muted`, `dark:bg-muted/50`) plus `aria-current`. Deviate when: never a dot or a check mark.
<!-- gen:house-defaults:end -->
## Hierarchy
- 1 focal point per card or panel: the dominant metric or task, then its label, then detail. Borders, separators and surfaces make 2 to 3 bands, never equal weight everywhere.
- Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments.
## Spacing and copy
- Siblings take the parent's `gap-*`, never their own margins; a heading sits nearer its own content than the section above it. Never pad an operational surface like a marketing page.
- Titles 1 to 3 words, a description 1 clause (about 8 words), a context line 2 segments at most, labels and buttons 1 to 2 words, a toast title 4 words at most. Copy that wraps at desktop width is over budget: cut it, never shrink the type.
## Surfaces
- `Frame` for data and operational surfaces (tables, lists, tool panels); `Card` for bounded sections and marketing.
- Prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card.
- A `data-grid` inside a Frame is the one place for `<FramePanel className="p-0">`; elsewhere no padding on slots. This is the 1 exception to the Slot padding row above.
- 1 archetype per screen (operational ones and the marketing spine): [compose.md](./compose.md) lists each with its regions.
## Type and buttons
- A KPI value is `text-2xl font-semibold tracking-tight tabular-nums`.
- `size="sm"` on dense surfaces, `variant="outline"` for secondary actions, `ghost` for row actions; 1 primary per region, 1 variant per toolbar.
- A toolbar separator is `<Separator orientation="vertical" className="my-auto h-4" />`; at a 390px viewport a toolbar stays on 1 row.
## States and responsive
- Empty: a short message plus the primary action. Loading: a `Skeleton` in the real layout, never a centered spinner. Error: inline with a retry.
- No horizontal page scroll at 390px: reflow to 1 column instead of shrinking; `min-w-0` on the shrinking parent and `truncate` on single-line labels.
+80
View File
@@ -0,0 +1,80 @@
# Motion
## Installed motion: never retime it
<!-- gen:installed-motion:start -->
| Installed part | Duration |
| ------------------ | ----------------- |
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
<!-- gen:installed-motion:end -->
Never add or change `duration-*`, `ease-*`, `animate-*` or `transition-*` on installed parts (`components/ui`, `components/reui`); Button already presses with `active:not-aria-[haspopup]:translate-y-px`.
## House motion: your own code
<!-- gen:house-motion:start -->
| Use | Tailwind |
| ------------------------- | ------------------------------------------------------- |
| Hover, press, color | `transition-colors` (150ms, no duration or ease class) |
| Reveal or expand | `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` |
| Large surface (ceiling) | `duration-300` with the same curve |
| An overlay you build | `duration-100` with the same curve |
| Media hover zoom | `duration-500` with the same curve, `scale-105` at most |
| Width tied to the sidebar | `duration-200 ease-linear` |
<!-- gen:house-motion:end -->
- Press on a custom surface (not Button): `transition-transform active:scale-[0.98] motion-reduce:transition-none`.
- Hover color plus press on 1 element: `transition-[color,background-color,border-color,scale]` (`transition-colors` and `transition-transform` override each other; `scale-*` sets `scale`).
- motion/react takes the same values in seconds: `{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }`; hover `{ duration: 0.15 }`.
- Drag, reorder and layout use `const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }`. Define `SPRING` only when something drags, reorders or toggles layout.
## Hard rules
- Animate `transform` and `opacity`; color only for hover and press. Never `transition-all`; width, height, top or left only in the sidebar row.
- Never `animate-bounce` or a spring with bounce above 0. `ease-linear` only for progress, spinners, loops and the sidebar; never Tailwind's `ease-in`, `ease-out` or `ease-in-out`.
- `motion/react` only: never framer-motion, GSAP or react-spring.
- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its `Skeleton`) is a reveal, not an entrance: fade it in with the reveal row.
- Never add named `duration-*` utilities or redefine `--ease-out`.
- Loading: a `Skeleton` (it already pulses) in the real layout; a spinner only inside a busy button.
- Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its `installAnimated` command; never retime it.
## Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare `transition` (it also animates box-shadow and filter). (`reui/no-paint-animation`; 240px: judgement (no check))
- A hover lift is `transform` over a static shadow, or a shadow layer whose `opacity` fades; never a transitioned `hover:shadow-*`. (`reui/no-paint-animation`)
- CSS first: hover, press, reveals and ambient loops are Tailwind transitions, `tw-animate-css` or `@keyframes`. motion/react only for exit on unmount, drag, springs that follow input, `layout` and scroll-linked values. (judgement (no check))
- In motion/react, `x`, `y`, `scale`, `rotate`, `pathLength` and every SVG target run on the main thread: never `repeat: Infinity` on them (loop with CSS `@keyframes`), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element without `repeatDelay`, `repeatType: "mirror"` or `onUpdate`. (`reui/no-main-thread-loop`)
- SVG geometry (a rect's `width`, a circle's `r`) does not reflow the page but runs in motion's JS loop: one-shot only. (`reui/no-main-thread-loop`)
- Never set React state per frame (requestAnimationFrame, `useAnimationFrame`, `onUpdate`, scroll or pointermove listeners): bind styles to `useMotionValue`, `useTransform` or a CSS variable on the element. (`reui/no-frame-setstate`)
- A JS-driven loop (motion `repeat`, requestAnimationFrame, canvas) pauses offscreen with `useInView` or IntersectionObserver; `content-visibility: auto` never stops script, and a setInterval loop also pauses on `visibilitychange`. (`reui/loop-offscreen`)
- No permanent `will-change`: no `will-change-*` class or `willChange` style, except during a drag, removed when it ends. (`reui/will-change`)
- Stagger 50ms or less per step (`staggerChildren: 0.05`), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals use `whileInView` with `viewport={{ once: true }}`. (`reui/stagger-budget`; item count and tail: judgement (no check))
- A height reveal (`grid-template-rows`) starts only on a click or key press, runs 200ms or less, 1 at a time; never `initial={{ height: 0 }}`. (`reui/no-layout-transition`)
- The motion `layout` prop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through `@reui/sortable` or `@reui/kanban`. (judgement (no check))
- AnimatePresence `mode="wait"` only with an exit of 150ms or less; `initial={false}` around content present at first render. (`reui/stagger-budget`)
- Never hide the hero or the largest heading behind a motion/react `initial` at opacity 0 (hidden until JavaScript runs). (judgement (no check))
- At most 3 ambient loops per view: CSS `@keyframes` on transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (a `content-visibility: auto` wrapper with `contain-intrinsic-size: auto <h>` where nothing paints outside it, or IntersectionObserver toggling `animation-play-state`). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
## Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (`reui/reduced-motion`)
- The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (`reui/reduced-motion`)
- In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (`reui/reduced-motion`; half the viewport: judgement (no check))
- `<MotionConfig reducedMotion="user">` once at the app root (its default ignores the OS) AND a `motion-reduce:` path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (`reui/reduced-motion`)
- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a `transform` string keep animating, so a loop or those keys branch on `useReducedMotion()` and rest on the final value. (`reui/reduced-motion`)
- `useReducedMotion()` is null on the server: never let it pick markup (JSX, className, `initial`) or reach `animate` under `initial={false}`; branch only animate, transition, `while*` or exit values. (`reui/reduced-motion-ssr`)
- No SMIL (`<animate>`, `<animateMotion>`, `<animateTransform>`); canvas and requestAnimationFrame loops draw 1 resting frame under `prefers-reduced-motion: reduce`. (`reui/no-smil`, `reui/reduced-motion`)
- Smooth scroll only under `motion-safe:scroll-smooth`. A focus ring appears at once: never `transition-all`, `transition-shadow` or a bare `transition` on it. (`reui/reduced-motion`, `reui/focus-transition`)
- Loaders and typing dots keep their opacity pulse; every decorative loop gets `motion-reduce:animate-none`, never an inline `style={{ animation }}` (it beats the class). (`reui/reduced-motion`)
- Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (`reui/pausable-autoplay`)
- Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))
+57
View File
@@ -0,0 +1,57 @@
# Quality gates (security, accessibility, scroll, self-checks)
This is the **done gate**: before you call any ReUI work finished, call `get_audit_checklist` and pass every item below (plus the bar and the states in [layout.md](./layout.md)). Then run the self-checks, typecheck and lint, compare with [anti-patterns.md](./anti-patterns.md), and hand back in the 5 lines below.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance. Hover on a display-only card: make the card a link to its detail, or give it no hover.
- `aria-current="page"` on the current route link, `"location"` on the current in-page section link.
- Destructive confirms use `AlertDialog`, not `Dialog`, and cannot be dismissed while pending.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship their keyboard behavior; never re-implement it.
- Announce async UI: loading and error messages use `role="status"` or `aria-live`.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
## Self-checks
After every UI edit, run these with ripgrep on `<files>`: each file you wrote or changed, by path, never `components/ui` or `components/reui`. A clean check prints nothing; a hit prints `file:line:code`. The last filter drops comment lines.
```sh
# C1 banned motion: transition-all, bare or paint transitions, layout keys, bounce, overshoot, scale 0, hover and tap gestures, will-change
rg -nH 'transition-all|transition(-property)?: *[^;]*\b(all|width|height|top|left|margin|padding|box-shadow|filter)\b|[\x27\x60" ]transition( +(duration|ease|delay|hover|focus|active|group|motion)|[\x27\x60"])|transition-shadow|transition-\[[^\]]*\b(width|height|top|left|right|bottom|inset|margin|padding|filter|radius|background-position|background-size|all)\b|animate-bounce|will-change|willChange|bounce: *(0?\.0*[1-9]|[1-9])|[\x27"](backIn|backOut|backInOut|anticipate)[\x27"]|(cubic-bezier\(|ease: *\[)([^,]*,|([^,]*,){3})[ _]*(-|1\.[0-9]*[1-9]|[2-9])|scale: *0 *[,}]|\bscale-0\b|while(Hover|Tap|Focus)=|(initial|animate|exit)=\{\{[^}]*\b(width|height|maxHeight|top|left|margin|padding|boxShadow|filter|backdropFilter|borderRadius)[A-Za-z]*:' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 off-scale motion: a duration off 0, 100, 150, 200 and 300 (duration-500 only on a media hover zoom), ease-in or ease-out, a stagger step above 0.05
rg -nH 'duration-([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})\b|duration-\[|\b([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})ms\b|(^|[^0-9.])(0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)|1\.[0-9]+)s\b|duration: *([1-9]\b|0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)\b)|\bease-(in|out|in-out)\b|(staggerChildren: *|stagger\( *)(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *[^,}]*\b(i|index|idx)\b *\* *(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *(0?\.0[6-9]|0?\.[1-9]|[1-9])[0-9.]* *\* *\b(i|index|idx)\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 read by hand: a duration or delay that is not a number, every index delay (allowed: 0.05 or less on the first 6 items, as Math.min(i, 5) * 0.05, never on table rows)
rg -nH '([dD]uration|[dD]elay): *[^0-9 .]|delay: *[0-9.]+ *\*' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C3 state color: raw palette on a line that names a state
rg -nH '\b(text|bg|border|ring|fill|stroke)-(red|rose|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet)-[0-9]{2,3}\b' <files> | rg -i ':[0-9]+:.*\b(success|paid|active|completed?|done|approved|online|healthy|live|error|failed|critical|overdue|declined|rejected|cancell?ed|blocked|expired|warning|pending|degraded|due|info|processing|running|scheduled|queued)\b' | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 structure: a raw table, select or fixed overlay, framer-motion, Math.random() or Date.now()
rg -nH '<table\b|<select\b|fixed inset-0|from ["\x27]framer-motion|Math\.random\(|Date\.now\(' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 on a base-* style only (skip on radix-*): Radix asChild
rg -nH '\basChild\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
```
Fix every hit, run again, stop after the second run and list what is left with file and line. A C4 hit stays only when a comment in the file says why. These also stay, named on hand-back line 2: C1 on the motion.md sidebar row or on will-change set in a drag handler and removed when it ends; C2 on a media hover zoom; `Math.min(i, 5) * 0.05`.
No `rg` (a missing one also prints nothing): run each first pattern with your own search tool, skipping comment lines. No shell: read each changed file for what each `#` line names.
## Hand-back
End your reply with these 5 lines:
1. Installed: each item with its `previewUrl`.
2. Motion: each animated element and its [motion.md](./motion.md) table row, or "none".
3. States: covered, or which are missing.
4. Checks: C1 to C4, screenshots or "not visually verified", the audit checklist, typecheck, lint; each one run, or "not run" and why.
5. Deviations: any rule broken or check hit left, with file:line and why.
+44
View File
@@ -0,0 +1,44 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the ReUI components with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (a full dashboard or settings page). Pro or Ultimate license at install. Its surfaces, text and borders use your theme tokens; its category accents (chart series, tags, avatars) are fixed palette colors paired for light and dark.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`), plus a hover-animated variant (`@reui/icons/animated/<style>/<name>`) only for icons that carry `installAnimated`. Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
ReUI items install through the shadcn CLI as `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a ReUI tool returned it (`search`, a getter, `get_install_command`). The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## ReUI items vs shadcn/ui components
- **ReUI items** (the components, `c-*` examples, blocks, Motion Icons) install as `@reui/<name>`.
- **shadcn/ui components** that ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. They install from shadcn by bare name: `npx shadcn@latest add button --yes`.
- **`badge` and `alert` exist in both registries.** `@reui/badge` and `@reui/alert` are ReUI's components (the extra variants in [components.md](./components.md)); bare `badge` and `alert` are shadcn's.
- **In an item's `registryDependencies`**, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both for you.
- **Templates are not registry items.** Download one from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs a template.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks take surfaces, text and borders from your theme tokens, so a theme change re-tints them; category accents stay fixed by design.
## Free vs premium
- **Free, no license key:** the ReUI components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate) and Motion Icons (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
- **Templates** are downloads, not installs: free ones need only a ReUI account, and each template's page on reui.io says what the others need.
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+37
View File
@@ -0,0 +1,37 @@
# Theming: styles, radius, density and sizes
## Read your style
`components.json` `style` is `<base>-<style>`: the part after the first `-` names the style (`base-nova` is nova on Base UI). The style is baked into `components/ui` and `components/reui` at install, so the installed files hold your real values: read your installed `button.tsx` (usually `components/ui/button.tsx`) for heights, radius and padding before you build anything that sits beside a control.
## The style ladder
<!-- gen:style-ladder:start -->
| Style | Button `sm` / default / `lg` | Button radius | Card radius, shadow | Card spacing | Button text |
| ----- | ---------------------------- | -------------- | ---------------------------------------------------- | ------------ | -------------------- |
| vega | `h-8` / `h-9` / `h-10` | `rounded-md` | `rounded-xl`, `shadow-xs` | 6, sm 4 | `text-sm` |
| nova | `h-7` / `h-8` / `h-9` | `rounded-lg` | `rounded-xl` | 4, sm 3 | `text-sm` |
| maia | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-2xl` | 6, sm 4 | `text-sm` |
| lyra | `h-7` / `h-8` / `h-9` | `rounded-none` | `rounded-none` | 4, sm 3 | `text-xs` |
| mira | `h-6` / `h-7` / `h-8` | `rounded-md` | `rounded-lg` | 4, sm 3 | `text-xs/relaxed` |
| luma | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-4xl`, `shadow-md` | 6, sm 4 | `text-sm` |
| sera | `h-9` / `h-10` / `h-11` | `rounded-none` | square, `shadow-sm` | 8, sm 5 | `text-xs`, uppercase |
| rhea | `h-7` / `h-8` / `h-9` | `rounded-2xl` | `rounded-[min(var(--radius-4xl),24px)]`, `shadow-sm` | 5, sm 4 | `text-sm` |
<!-- gen:style-ladder:end -->
Card spacing is the `--card-spacing` step (`--spacing(n)`) a Card pads with; `Card size="sm"` uses the sm step. The default Button text weight is `font-medium` (sera: `font-semibold`).
## What the style owns
- Never set what the style owns on its parts: radius (except `rounded-full`), height, horizontal padding, shadow, ring or font size. Pick `size` and `variant` instead.
- A panel is a `Card` or a `Frame`, never a div with `rounded-*`, `border` and `bg-card`; set density with `Card size="sm"` or Frame `spacing`.
- A bespoke element beside a control (a chip, a custom trigger) takes its height and radius from that control's row above.
- A focusable control you build copies the focus classes of your installed `input.tsx`.
- A variant before a class: `className` is for layout (placement, width, gap). `size-*` when width equals height, `cn()` for conditional classes (never a template literal), a scale step before an arbitrary value (`w-60`, not `w-[240px]`), no inline `style` for color or spacing, and no `z-index` on dialogs, sheets or popovers.
- Change the look through the theme CSS variables in `:root` and `.dark` (`--radius`, `--primary` and the rest) or with a style switch (a new `style` in `components.json`, which means reinstalling the components; ask the user first). Never edit installed files to restyle them.
## Components with no per-style values
`event-calendar`, `gantt`, `icon-stack`, `kanban`, `rating`, `scrollspy`, `sortable` and `timeline` install the same file in every style. Their look comes from your theme tokens and the parts you put inside them (a `Card` in a kanban column follows the style), so never copy another style's radius onto them.
+66
View File
@@ -0,0 +1,66 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## When to reach for ReUI vs plain shadcn
- A full page or section (dashboard, billing, auth, pricing, settings): `compose_page` first (step 1).
- A data table with sorting, filtering, pagination or virtualization: the `data-grid` component, never a hand-rolled `<table>`.
- A drag-and-drop board: the `kanban` component.
- Column filters, a date range, a tree, a stepper and the like: the matching ReUI component.
- A single generic control shadcn already has (Button, Dialog, Select): plain shadcn.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent)` FIRST (`sections`, 1 to 10, caps how many sections, default 8), before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (a ReUI component), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid" })` (on a free account use `type: "example"`).
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Pick with the guidance, not the title.** Look-alikes are common (dialog, sheet and drawer; dozens of data-grid examples and app shells). Before installing, read the item's guidance: `get_component` returns `whenToUse` and `whenNotToUse`, `get_example` and `get_block` return `bestFor` (what sets the item apart from its siblings), `agentNotes` and `whenNotToUse`, and a block adds `categoryGuidance` (its category's key note), and `get_examples` rows carry `bestFor`. Each `whenNotToUse` entry names the item to use instead and its `type` (`component` -> `get_component`, `example` -> `get_example`, `block` -> `get_block`, `block-category` "<group>/<category>" -> `search({ query, type: "block", category: "<category>" })`); when the user's situation matches its `when`, switch. Apply every `agentNotes` line before you ship: it names the real identifiers to replace or wire (sample data, stubbed actions, required props). An item with no guidance yet has none of these keys; fall back to its description and thumbnail.
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**Show the preview.** List every item with its `previewUrl` (a live preview page). Where a result carries `thumbnail` (blocks, `c-*` examples and icons), show the image as markdown linked to `previewUrl`, so the user sees it before you install.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. Write against your project's base (see [components.md](./components.md)). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to the active theme: semantic tokens for anything that carries meaning, raw palette only for categorical identity and always with its `dark:` pair (see [color.md](./color.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, motion that leaves installed components alone, and mobile-first responsiveness (see [layout.md](./layout.md) and [motion.md](./motion.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+74
View File
@@ -0,0 +1,74 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; Pro unlocks premium blocks, Ultimate adds Motion Icons, and either removes the daily request limit by default. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (`sections` is a number, 1 to 10: the most sections to return, default 8); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Only some icons have a real hover animation: `animated: true` returns only those, and a concept with none comes back empty with an `animatedNote`. Each icon carries a `thumbnail` of its 4 styles; look at it before trusting a name. An old icon name still resolves (`renamedFrom` names it), unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word and `oldNameNotes` names the icon the old name installs. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale. With no `topic` it returns the core workflow, the topic index and the installer; `topic` (for example `motion`) returns that one file in full. Compare its `version` with the one stamped atop your local `SKILL.md`.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Commands (MCP prompts)
In Claude Code the ReUI MCP adds these prompts; other agents show MCP prompts under their own names, or not at all. Plain language runs the same loop.
- `/mcp__reui__build <what>`: compose a page, section or feature (plan, install, read the API, adapt, audit).
- `/mcp__reui__add <item>`: find, install and wire in 1 component, example, block or icon.
- `/mcp__reui__fix [target]`: fix ReUI usage (undocumented props, a base or radix mismatch, missing states, accessibility, scroll).
- `/mcp__reui__improve [target]`: refine existing ReUI UI (hierarchy, density, states, responsive, motion).
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- Icon results and `get_icon` include `animated: true` and `installAnimated` only when the icon has a real hover animation (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`). Use the strings given; never build an animated path by hand, it installs a static copy for every other icon.
- Blocks, `c-*` examples and icons carry `thumbnail`, an absolute URL to a hosted preview image (light theme; blocks 1080x720, examples 900x600, icons 640x200 with the 4 styles side by side). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Guidance is getter-only, never on search rows: `get_component` adds `whenToUse`, `whenNotToUse` and `agentNotes`; `get_example` and `get_block` add `bestFor`, `agentNotes` and `whenNotToUse`, and `get_block` also `categoryGuidance`; `get_examples` rows add `bestFor`. Answers carry the key lines; the item page's `.md` twin has every line. Each `whenNotToUse` entry is `{ when, instead, type }`: fetch `instead` with the getter its `type` names.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+9 -11
View File
@@ -5,40 +5,38 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup",
"build": "tsup src/server.ts --format esm --dts",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "node dist/server.js",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage"
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.0.1",
"@fastify/formbody": "^9.0.0",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"@simplewebauthn/server": "^13.3.2",
"better-sqlite3": "^11.10.0",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.8",
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-v8": "4.1.10",
"eslint": "catalog:",
"globals": "catalog:",
"@eslint/js": "^10.0.1",
"@types/node": "^22.15.32",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"typescript-eslint": "^8.64.0",
"vitest": "catalog:"
}
}
+33 -165
View File
@@ -1,9 +1,9 @@
import type { FastifyError, FastifyInstance } from 'fastify'
import type { FastifyInstance } from 'fastify'
import Fastify from 'fastify'
import compress from '@fastify/compress'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
@@ -14,12 +14,8 @@ import {
allPermissionKeys,
} from '@authportal/shared'
import {
closeDb,
createDb,
getAppSwitcherConfig,
getUserApps,
hasAnyUser,
listAdmins,
healthCheck,
migrateSchema,
setUserAccess,
@@ -27,13 +23,9 @@ import {
type AppDb,
type Sqlite,
} from '@authportal/db'
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
import { hash } from '@node-rs/argon2'
import { randomUUID } from 'node:crypto'
import {
assertBootstrapPassword,
parseTrustProxy,
type AppConfig,
} from './config.js'
import type { AppConfig } from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
@@ -41,23 +33,7 @@ import { auditIngestRoutes } from './routes/ingest-audit.js'
import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { startHousekeepingJob } from './services/housekeeping.js'
import { configurePortalRevocationSender } from './services/portal-revocations.js'
import { startBackupJob } from './services/backup.js'
import {
ensureOidcSigningKey,
isOidcKeyReady,
resetOidcKeyCache,
} from './lib/oidc/keys.js'
import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
function boolFlag(v: string): boolean {
return v === '1' || v.toLowerCase() === 'true'
}
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
declare module 'fastify' {
interface FastifyInstance {
@@ -67,11 +43,11 @@ declare module 'fastify' {
}
}
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
if (!hasAnyUser(app.db)) {
assertBootstrapPassword(app.config)
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
const existing = app.db.select().from(users).all()
if (existing.length === 0) {
const now = new Date().toISOString()
const passwordHash = await hashPassword(app.config.adminPassword)
const passwordHash = await hash(app.config.adminPassword)
const id = randomUUID()
app.db
.insert(users)
@@ -93,7 +69,8 @@ export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void>
}
// Ensure existing admins have app tiles if access was never set
for (const user of listAdmins(app.db)) {
for (const user of existing) {
if (!user.isAdmin) continue
const apps = getUserApps(app.db, user.id)
if (apps.length === 0) {
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
@@ -114,114 +91,32 @@ export async function buildApp(opts: {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
setPasswordConcurrency(config.argon2Concurrency)
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: {
level: config.logLevel,
// Секреты не должны попадать в логи, даже если сериализатор начнёт отдавать заголовки.
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
],
censor: '[redacted]',
},
},
trustProxy: parseTrustProxy(config.trustProxy),
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
keepAliveTimeout: 65_000,
connectionTimeout: 0,
requestTimeout: 30_000,
bodyLimit: 1_048_576,
logger: { level: config.logLevel },
trustProxy: true,
})
app.decorate('config', config)
app.decorate('db', db)
app.decorate('sqlite', sqlite)
// Configure before bootstrap/access writes; failed opt-in must close this owned handle.
try { configurePortalRevocationSender(app) } catch (error) { closeDb(sqlite); throw error }
app.addHook('onClose', async () => {
closeDb(sqlite)
})
app.setErrorHandler((err: FastifyError | Error, req, reply) => {
const status =
err.name === 'ZodError'
? 400
: (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400
? (err as FastifyError).statusCode!
: 500
if (status >= 500) {
req.log.error(err)
return reply.status(500).send({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
}
const code =
status === 429
? 'RATE_LIMITED'
: status === 400
? 'BAD_REQUEST'
: ((err as FastifyError).code ?? 'ERROR')
return reply.status(status).send({ error: { code, message: err.message } })
})
const originAllowed = createOriginChecker(config, () =>
getAppSwitcherConfig(db).apps.map((a) => a.url),
)
if (boolFlag(config.enableCompress)) {
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
}
await app.register(sensible)
await app.register(formbody)
await app.register(cors, {
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
credentials: true,
})
await app.register(helmet, {
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
})
await app.register(rateLimit, {
max: config.rateLimitMax,
timeWindow: '1 minute',
// пробы Docker/Traefik не должны упираться в лимит
allowList: (req) => req.url === '/health' || req.url === '/ready',
})
await app.register(cors, { origin: true, credentials: true })
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' })
await app.register(fjwt, { secret: config.jwtSecret })
// liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили
// контейнер из-за временной блокировки.
app.get('/health', async () => ({ ok: true }))
app.get('/health', async () => {
healthCheck(sqlite)
return { ok: true }
})
// readiness: БД отвечает и ключ подписи OIDC загружен.
app.get('/ready', async (_request, reply) => {
try {
healthCheck(sqlite)
} catch {
return reply.status(503).send({ ok: false, reason: 'db' })
}
if (!isOidcKeyReady()) {
return reply.status(503).send({ ok: false, reason: 'oidc_key' })
}
app.get('/ready', async () => {
healthCheck(sqlite)
return { ok: true }
})
@@ -239,55 +134,28 @@ export async function buildApp(opts: {
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
const stopHousekeeping = startHousekeepingJob(app)
const stopBackup = startBackupJob(app)
app.addHook('onClose', async () => {
stopRetention()
stopHousekeeping()
stopBackup()
})
if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) {
app.log.warn(
'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' +
'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron',
)
}
}
if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, {
root: resolve(config.staticDir),
wildcard: false,
// .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
// Traefik перед приложением не сжимает; .gz/.br для статики готовит
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
preCompressed: true,
// Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
cacheControl: false,
setHeaders(res, filePath) {
if (HASHED_ASSET_RE.test(filePath)) {
// Vite кладёт хеш в имя файла → кэш на год без ревалидации.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else if (INDEX_HTML_RE.test(filePath)) {
// Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
res.setHeader('Cache-Control', 'no-cache')
} else {
res.setHeader('Cache-Control', 'public, max-age=0')
}
},
})
app.setNotFoundHandler((req, reply) => {
if (req.method === 'GET' && !req.url.startsWith('/api')) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
}
const spa = Boolean(config.staticDir && existsSync(config.staticDir))
app.setNotFoundHandler((req, reply) => {
if (
spa &&
req.method === 'GET' &&
!API_PREFIXES.some((p) => req.url.startsWith(p))
) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
return app
}
+1 -83
View File
@@ -5,17 +5,6 @@ const boolFromEnv = (v: string | undefined, fallback: boolean) => {
return v === '1' || v.toLowerCase() === 'true'
}
/** Loopback + приватные сети (Docker/Traefik). */
export const DEFAULT_TRUST_PROXY =
'127.0.0.1/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7'
export function parseTrustProxy(raw: string): boolean | string[] {
const v = raw.trim()
if (v === 'true') return true
if (v === 'false' || v === '') return false
return v.split(',').map((s) => s.trim()).filter(Boolean)
}
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
@@ -33,31 +22,8 @@ export const configSchema = z.object({
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
/** Каталог бэкапов SQLite; по умолчанию <каталог БД>/backups. */
backupDir: z.string().optional(),
/** Сколько последних копий хранить. */
backupKeep: z.coerce.number().int().positive().default(7),
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
backupIntervalHours: z.coerce.number().min(0).default(0),
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
enableCompress: z.string().default('false'),
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
rateLimitMax: z.coerce.number().int().positive().default(200),
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
argon2Concurrency: z.coerce.number().int().positive().default(2),
/** 'true' | 'false' | список IP/CIDR доверенных прокси. */
trustProxy: z.string().default(DEFAULT_TRUST_PROXY),
/** Сколько дней хранить истёкшие/отозванные сессии и коды перед удалением. */
sessionRetentionDays: z.coerce.number().positive().default(7),
/** Максимум на graceful shutdown, затем принудительный выход. */
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
isProd: z.boolean(),
auditIngestSecret: z.string().min(8).optional(),
portalRevocationsEnabled: z.boolean().default(false),
portalRevocationsTarget: z.string().optional(),
portalRevocationsSecret: z.string().optional(),
webauthnRpID: z.string().min(1),
webauthnRpName: z.string().min(1).default('Auth Portal'),
webauthnOrigins: z.array(z.string().url()).min(1),
@@ -65,39 +31,6 @@ export const configSchema = z.object({
export type AppConfig = z.infer<typeof configSchema>
const WEAK_JWT_SECRETS = new Set([
'change-me-in-production',
'dev-secret-change-me',
'secret',
'changeme',
])
/** В production запрещены известные дефолты JWT_SECRET; короткий секрет — только предупреждение. */
function assertProductionSecrets(config: AppConfig): void {
if (!config.isProd) return
if (WEAK_JWT_SECRETS.has(config.jwtSecret)) {
throw new Error(
'JWT_SECRET: в production нельзя использовать значение по умолчанию — задайте уникальный секрет',
)
}
if (config.jwtSecret.length < 16) {
// Не роняем старые установки с коротким, но уникальным секретом.
process.emitWarning(
'JWT_SECRET короче 16 символов — смените на более длинный (смена разлогинит пользователей)',
)
}
}
/** Пароль первого администратора: в production — не дефолтный и не короче 8 символов. */
export function assertBootstrapPassword(config: AppConfig): void {
if (!config.isProd) return
if (!config.adminPassword || config.adminPassword === 'admin' || config.adminPassword.length < 8) {
throw new Error(
'ADMIN_PASSWORD: для создания первого администратора в production задайте пароль не короче 8 символов (не "admin")',
)
}
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
@@ -107,7 +40,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
const config = configSchema.parse({
return configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
@@ -123,29 +56,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
backupDir: env.BACKUP_DIR || undefined,
backupKeep: env.BACKUP_KEEP ?? 7,
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
enableCompress: env.ENABLE_COMPRESS ?? 'false',
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
isProd:
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd,
auditIngestSecret,
portalRevocationsEnabled: boolFromEnv(env.MCP_REVOCATIONS_ENABLED, false),
portalRevocationsTarget: env.MCP_REVOCATIONS_TARGET || undefined,
portalRevocationsSecret: env.MCP_REVOCATIONS_SECRET || undefined,
webauthnRpID: rpID,
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins,
})
assertProductionSecrets(config)
return config
}
function webauthnFromIssuer(
+4 -1
View File
@@ -5,7 +5,10 @@ import {
} from '@authportal/db'
export function clientIp(request: FastifyRequest): string | null {
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
const forwarded = request.headers['x-forwarded-for']
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]?.trim() ?? null
}
return request.ip ?? null
}
-51
View File
@@ -1,51 +0,0 @@
/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */
export class TtlCache<K, V> {
private readonly map = new Map<K, { value: V; expires: number }>()
constructor(
private readonly opts: {
max: number
ttlMs: number
now?: () => number
},
) {}
private now(): number {
return (this.opts.now ?? Date.now)()
}
get(key: K): V | undefined {
const hit = this.map.get(key)
if (!hit) return undefined
if (hit.expires <= this.now()) {
this.map.delete(key)
return undefined
}
// освежаем позицию для LRU
this.map.delete(key)
this.map.set(key, hit)
return hit.value
}
set(key: K, value: V): void {
this.map.delete(key)
this.map.set(key, { value, expires: this.now() + this.opts.ttlMs })
while (this.map.size > this.opts.max) {
const oldest = this.map.keys().next()
if (oldest.done) break
this.map.delete(oldest.value)
}
}
delete(key: K): void {
this.map.delete(key)
}
clear(): void {
this.map.clear()
}
get size(): number {
return this.map.size
}
}
-35
View File
@@ -1,35 +0,0 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { verifyPassword } from './password.js'
const PREFIX = 'sha256:'
/**
* Секреты OIDC-клиентов — 32 случайных байта (высокая энтропия), поэтому
* медленный argon2 не нужен: SHA-256 + сравнение за постоянное время.
*/
export function hashClientSecret(secret: string): string {
return PREFIX + createHash('sha256').update(secret).digest('hex')
}
export type ClientSecretCheck = { ok: boolean; needsRehash: boolean }
export async function checkClientSecret(
stored: string,
secret: string,
): Promise<ClientSecretCheck> {
if (stored.startsWith(PREFIX)) {
const a = Buffer.from(stored)
const b = Buffer.from(hashClientSecret(secret))
return {
ok: a.length === b.length && timingSafeEqual(a, b),
needsRehash: false,
}
}
// Устаревший формат (argon2): проверяем и просим перехешировать при успехе.
try {
const ok = await verifyPassword(stored, secret)
return { ok, needsRehash: ok }
} catch {
return { ok: false, needsRehash: false }
}
}
-27
View File
@@ -1,27 +0,0 @@
/** Значение cookie без построения RegExp на каждый запрос. */
export function parseCookie(
header: string | undefined,
name: string,
): string | null {
if (!header) return null
let from = 0
while (from < header.length) {
let end = header.indexOf(';', from)
if (end === -1) end = header.length
const eq = header.indexOf('=', from)
if (eq !== -1 && eq < end) {
const key = header.slice(from, eq).trim()
if (key === name) {
const raw = header.slice(eq + 1, end).trim()
if (!raw) return null
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
}
from = end + 1
}
return null
}
-50
View File
@@ -1,50 +0,0 @@
import { isReturnToAllowed } from '@authportal/shared'
import type { AppConfig } from '../config.js'
import { oidcIssuerFromConfig } from '../config.js'
function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
return null
}
}
/**
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
* разрешить всё (только осознанно).
*/
export function createOriginChecker(
config: AppConfig,
switcherUrls: () => string[],
): (origin: string) => boolean {
const explicit = new Set<string>()
let allowAll = false
for (const raw of config.corsOrigins.split(',')) {
const v = raw.trim()
if (!v) continue
if (v === '*') allowAll = true
else {
const o = originOf(v)
if (o) explicit.add(o)
}
}
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
const o = originOf(u)
if (o) explicit.add(o)
}
return (origin: string) => {
if (allowAll) return true
const o = originOf(origin)
if (!o) return false
if (explicit.has(o)) return true
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
for (const u of switcherUrls()) {
if (originOf(u) === o) return true
}
return false
}
}
+5 -8
View File
@@ -12,20 +12,17 @@ import {
type UserRow,
} from '@authportal/db'
import { toMe } from '../plugins/auth-guards.js'
import { getUserProfile } from './user-profile.js'
/** Mint access JWT + Me from current DB state (login / reissue). */
export function issueAccessToken(
app: FastifyInstance,
user: UserRow,
): LoginResponse {
// Профиль из кэша (инвалидируется записями в users/доступ); user — свежая строка вызывающего.
const profile = getUserProfile(app.db, user.id)
const apps = profile?.apps ?? getUserApps(app.db, user.id)
const permissions = user.isAdmin
? allPermissionKeys()
: (profile?.permissions ??
normalizePermissionKeys(getUserPermissions(app.db, user.id)))
const apps = getUserApps(app.db, user.id)
let permissions = normalizePermissionKeys(getUserPermissions(app.db, user.id))
if (user.isAdmin) {
permissions = allPermissionKeys()
}
const switcher = getAppSwitcherConfig(app.db)
const tenants = tenantsClaimForUser(switcher, apps)
const me = toMe(user, apps, permissions)
-22
View File
@@ -113,16 +113,10 @@ export async function ensureOidcSigningKey(
return cached
}
/** Ключ подписи загружен — приложение готово выдавать токены. */
export function isOidcKeyReady(): boolean {
return cached !== null
}
/** Reset cache (tests). */
export function resetOidcKeyCache(): void {
cached = null
importedPublicKeys.clear()
jwksCache = new WeakMap()
}
export function buildOidcDiscovery(config: AppConfig) {
@@ -163,17 +157,6 @@ export async function buildJwks(
app: FastifyInstance,
): Promise<{ keys: JWK[] }> {
await ensureOidcSigningKey(app)
const hit = jwksCache.get(app.db)
if (hit && hit.kid === cached?.kid) return hit.jwks
const jwks = computeJwks(app)
jwksCache.set(app.db, { kid: cached?.kid ?? '', jwks })
return jwks
}
/** Мемоизация JWKS: набор ключей меняется только при выпуске нового (меняется kid). */
let jwksCache = new WeakMap<object, { kid: string; jwks: { keys: JWK[] } }>()
function computeJwks(app: FastifyInstance): { keys: JWK[] } {
const keys = listOidcSigningKeys(app.db)
.map((row): JWK | null => {
try {
@@ -191,11 +174,6 @@ function computeJwks(app: FastifyInstance): { keys: JWK[] } {
return { keys }
}
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
export function invalidateJwks(app: FastifyInstance): void {
jwksCache.delete(app.db)
}
export async function signOidcJwt(
app: FastifyInstance,
claims: Record<string, unknown>,
-58
View File
@@ -1,58 +0,0 @@
import { hash, verify } from '@node-rs/argon2'
/**
* argon2 держит ~19 МиБ на вызов и занимает поток libuv; без ограничителя
* шквал логинов раздувает RSS. Семафор ограничивает число одновременных хешей.
*/
let limit = 2
let active = 0
const waiters: Array<() => void> = []
export function setPasswordConcurrency(n: number): void {
limit = Math.max(1, Math.floor(n))
}
export function passwordHashStats(): { active: number; waiting: number } {
return { active, waiting: waiters.length }
}
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (active >= limit) {
await new Promise<void>((resolve) => waiters.push(resolve))
} else {
active += 1
}
try {
return await fn()
} finally {
const next = waiters.shift()
if (next) next() // слот передаётся ожидающему без изменения active
else active -= 1
}
}
export function hashPassword(password: string): Promise<string> {
return withSlot(() => hash(password))
}
export function verifyPassword(
passwordHash: string,
password: string,
): Promise<boolean> {
return withSlot(() => verify(passwordHash, password))
}
let dummyHash: Promise<string> | null = null
/**
* Для неизвестного/отключённого пользователя выполняем настоящую проверку
* по фиктивному хешу: время ответа не выдаёт существование учётки.
*/
export async function verifyDummyPassword(password: string): Promise<void> {
dummyHash ??= hash('dummy-password-for-timing')
try {
await withSlot(async () => verify(await dummyHash!, password))
} catch {
/* результат не важен */
}
}
-67
View File
@@ -1,67 +0,0 @@
import type { FastifyInstance } from 'fastify'
export interface ShutdownOptions {
timeoutMs: number
exit?: (code: number) => void
signals?: NodeJS.Signals[]
proc?: Pick<NodeJS.Process, 'on' | 'off'>
}
/**
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
* Возвращает функцию отписки и ручной триггер (для тестов).
*/
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
const exit = opts.exit ?? ((code: number) => process.exit(code))
const proc = opts.proc ?? process
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
let closing: Promise<void> | null = null
const shutdown = (reason: string, code = 0): Promise<void> => {
if (closing) {
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
exit(1)
return closing
}
app.log.info(`shutdown: ${reason}`)
const timer = setTimeout(() => {
app.log.error('shutdown: timeout, forcing exit')
exit(1)
}, opts.timeoutMs)
timer.unref()
closing = app
.close()
.then(() => {
clearTimeout(timer)
exit(code)
})
.catch((err: unknown) => {
clearTimeout(timer)
app.log.error(err, 'shutdown: close failed')
exit(1)
})
return closing
}
const handlers = signals.map((sig) => {
const h = () => void shutdown(sig)
proc.on(sig, h)
return [sig, h] as const
})
const onFatal = (err: unknown) => {
app.log.error(err, 'fatal error')
void shutdown('fatal', 1)
}
proc.on('unhandledRejection', onFatal)
proc.on('uncaughtException', onFatal)
return {
shutdown,
dispose() {
for (const [sig, h] of handlers) proc.off(sig, h)
proc.off('unhandledRejection', onFatal)
proc.off('uncaughtException', onFatal)
},
}
}
-70
View File
@@ -1,70 +0,0 @@
import {
getUserApps,
getUserById,
getUserDataGeneration,
getUserPermissions,
type AppDb,
type UserRow,
} from '@authportal/db'
import { allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { TtlCache } from './cache.js'
export type UserProfile = {
row: UserRow
/** Сырые app_id из БД. */
apps: string[]
/** Нормализованные права; у админа — полный каталог. */
permissions: string[]
}
type Entry = { profile: UserProfile; generation: number }
/** Страховочный TTL: основная инвалидация — по счётчику поколения из @authportal/db. */
const PROFILE_TTL_MS = 10_000
const PROFILE_MAX = 500
const caches = new WeakMap<AppDb, TtlCache<string, Entry>>()
function cacheFor(db: AppDb): TtlCache<string, Entry> {
let c = caches.get(db)
if (!c) {
c = new TtlCache({ max: PROFILE_MAX, ttlMs: PROFILE_TTL_MS })
caches.set(db, c)
}
return c
}
export function loadUserProfile(db: AppDb, id: string): UserProfile | undefined {
const row = getUserById(db, id)
if (!row) return undefined
return profileFromRow(db, row)
}
function profileFromRow(db: AppDb, row: UserRow): UserProfile {
const apps = getUserApps(db, row.id)
const permissions = row.isAdmin
? allPermissionKeys()
: normalizePermissionKeys(getUserPermissions(db, row.id))
return { row, apps, permissions }
}
/**
* Профиль пользователя (строка + приложения + права) с кэшем: 3 SELECT на
* запрос → 0 пока данные пользователей не менялись.
*/
export function getUserProfile(
db: AppDb,
id: string,
): UserProfile | undefined {
const cache = cacheFor(db)
const generation = getUserDataGeneration()
const hit = cache.get(id)
if (hit && hit.generation === generation) return hit.profile
const profile = loadUserProfile(db, id)
if (!profile) {
cache.delete(id)
return undefined
}
cache.set(id, { profile, generation })
return profile
}
+27 -14
View File
@@ -1,8 +1,12 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import type { UserRow } from '@authportal/db'
import {
getUserApps,
getUserById,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared'
import { APP_IDS } from '@authportal/shared'
import { getUserProfile, type UserProfile } from '../lib/user-profile.js'
import { APP_IDS, allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
export type AuthUser = {
id: string
@@ -63,15 +67,24 @@ export function toMe(user: UserRow, apps: string[], permissions: string[]): MeRe
}
}
export function authUserFromProfile(profile: UserProfile): AuthUser {
const { row } = profile
export function loadAuthUser(
request: FastifyRequest,
user: UserRow,
): AuthUser {
const apps = getUserApps(request.server.db, user.id)
let permissions = normalizePermissionKeys(
getUserPermissions(request.server.db, user.id),
)
if (user.isAdmin) {
permissions = allPermissionKeys()
}
return {
id: row.id,
email: row.email,
name: row.name,
isAdmin: row.isAdmin,
apps: asAppIds(profile.apps),
permissions: profile.permissions,
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
apps: asAppIds(apps),
permissions,
}
}
@@ -88,14 +101,14 @@ export async function requireAuth(
}
const sub = request.user.sub
const profile = getUserProfile(request.server.db, sub)
if (!profile || profile.row.disabled) {
const row = getUserById(request.server.db, sub)
if (!row || row.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
request.authUser = authUserFromProfile(profile)
request.authUser = loadAuthUser(request, row)
}
export async function requireAdmin(
+11 -16
View File
@@ -1,6 +1,5 @@
import type { FastifyInstance } from 'fastify'
import { hashPassword } from '../lib/password.js'
import { hashClientSecret } from '../lib/client-secret.js'
import { hash } from '@node-rs/argon2'
import {
countWebauthnCredentials,
countWebauthnCredentialsBatch,
@@ -28,7 +27,6 @@ import {
setUserAccess,
updateOidcClient,
updateUser,
withTransaction,
} from '@authportal/db'
import {
APP_IDS,
@@ -170,17 +168,14 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const passwordHash = await hashPassword(data.password)
const user = withTransaction(app.db, () => {
const created = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
})
setUserAccess(app.db, created.id, data.apps, permissions)
return created
const passwordHash = await hash(data.password)
const user = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
})
setUserAccess(app.db, user.id, data.apps, permissions)
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
safeAudit(app, {
action: 'user.create',
@@ -223,7 +218,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}
const passwordHash = parsed.data.password
? await hashPassword(parsed.data.password)
? await hash(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
@@ -468,7 +463,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const secret = generateOidcClientSecret()
const secretHash = hashClientSecret(secret)
const secretHash = await hash(secret)
const row = createOidcClient(app.db, {
name: parsed.data.name,
clientSecretHash: secretHash,
@@ -550,7 +545,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const secret = generateOidcClientSecret()
const secretHash = hashClientSecret(secret)
const secretHash = await hash(secret)
const row = updateOidcClient(app.db, request.params.id, {
clientSecretHash: secretHash,
})!
+28 -24
View File
@@ -1,4 +1,5 @@
import type { FastifyInstance } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
PERMISSION_CATALOG,
appsMetaFromSwitcher,
@@ -10,11 +11,9 @@ import {
getAppSwitcherConfig,
getUserByEmail,
getUserById,
searchDirectoryUsers,
listUsers,
revokeRefreshSession,
} from '@authportal/db'
import { verifyDummyPassword, verifyPassword } from '../lib/password.js'
import { parseCookie } from '../lib/cookies.js'
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
@@ -23,14 +22,11 @@ import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
export async function authRoutes(app: FastifyInstance): Promise<void> {
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
app.get('/api/v1/auth/config', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=60')
return {
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
webauthn: true,
}
})
app.get('/api/v1/auth/config', async () => ({
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
webauthn: true,
}))
app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
@@ -48,7 +44,6 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const targetApp = targetAppFromReturnTo(returnTo)
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
await verifyDummyPassword(password)
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
@@ -68,7 +63,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
}
const ok = await verifyPassword(user.passwordHash, password)
const ok = await verify(user.passwordHash, password)
if (!ok) {
safeAudit(app, {
action: 'auth.login_failed',
@@ -168,13 +163,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
},
)
app.post(
'/api/v1/auth/logout',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (request, reply) => {
const refresh = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (refresh) {
revokeRefreshSession(app.db, refresh)
app.post('/api/v1/auth/logout', async (request, reply) => {
const cookie = request.headers.cookie ?? ''
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
if (match?.[1]) {
revokeRefreshSession(app.db, match[1])
}
reply.header(
'Set-Cookie',
@@ -212,11 +205,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
return { ok: true }
},
)
})
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
// Без Cache-Control: после сохранения в админке SPA должна сразу видеть новый конфиг.
app.get('/api/v1/app-switcher', async () =>
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
)
@@ -246,7 +237,20 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
)
.trim()
.toLowerCase()
return searchDirectoryUsers(app.db, q, 50)
const all = listUsers(app.db).filter((u) => !u.disabled)
const filtered = q
? all.filter(
(u) =>
u.email.toLowerCase().includes(q) ||
u.name.toLowerCase().includes(q) ||
u.id.toLowerCase().includes(q),
)
: all
return filtered.slice(0, 50).map((u) => ({
id: u.id,
email: u.email,
name: u.name,
}))
},
)
+25 -21
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'
import { appendAuditBatch } from '@authportal/db'
import { appendAudit } from '@authportal/db'
import {
ingestAuditRequestSchema,
type AuditSourceApp,
@@ -45,26 +45,30 @@ export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
})
}
const { accepted, duplicates, failed } = appendAuditBatch(
app.db,
parsed.data.events.map((event) => ({
eventId: event.event_id,
sourceApp: event.source_app as AuditSourceApp,
action: event.action,
severity: event.severity,
actorUserId: event.actor_user_id,
actorEmail: event.actor_email,
actorName: event.actor_name,
targetType: event.target_type,
targetId: event.target_id,
summary: event.summary,
details: event.details,
ip: event.ip,
createdAt: event.created_at,
})),
)
if (failed > 0) {
app.log.warn({ failed }, 'ingest append failed for some events')
let accepted = 0
let duplicates = 0
for (const event of parsed.data.events) {
try {
const inserted = appendAudit(app.db, {
eventId: event.event_id,
sourceApp: event.source_app as AuditSourceApp,
action: event.action,
severity: event.severity,
actorUserId: event.actor_user_id,
actorEmail: event.actor_email,
actorName: event.actor_name,
targetType: event.target_type,
targetId: event.target_id,
summary: event.summary,
details: event.details,
ip: event.ip,
createdAt: event.created_at,
})
if (inserted) accepted += 1
else duplicates += 1
} catch (err) {
app.log.warn({ err, event_id: event.event_id }, 'ingest append failed')
}
}
return { accepted, duplicates }
+18 -23
View File
@@ -1,7 +1,7 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
updateOidcClient,
createOidcAuthCode,
consumeOidcAuthCode,
getOidcClientByClientId,
@@ -19,8 +19,6 @@ import {
} from '@authportal/shared'
import { oidcIssuerFromConfig } from '../config.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { checkClientSecret, hashClientSecret } from '../lib/client-secret.js'
import { parseCookie } from '../lib/cookies.js'
import {
buildJwks,
buildOidcDiscovery,
@@ -35,6 +33,15 @@ const CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TTL_SEC = 3600
const ID_TOKEN_TTL_SEC = 3600
function cookieValue(
cookieHeader: string | undefined,
name: string,
): string | null {
if (!cookieHeader) return null
const match = cookieHeader.match(new RegExp(`${name}=([^;]+)`))
return match?.[1] ? decodeURIComponent(match[1]) : null
}
function parseBasicAuth(
header: string | undefined,
): { clientId: string; clientSecret: string } | null {
@@ -53,22 +60,14 @@ function parseBasicAuth(
}
async function authenticateClient(
app: FastifyInstance,
client: OidcClientRow,
secret: string,
): Promise<boolean> {
const res = await checkClientSecret(client.clientSecretHash, secret)
if (res.ok && res.needsRehash) {
// Миграция argon2 → SHA-256 при первом успешном обмене.
try {
updateOidcClient(app.db, client.id, {
clientSecretHash: hashClientSecret(secret),
})
} catch (err) {
app.log.warn({ err }, 'client secret rehash failed')
}
try {
return await verify(client.clientSecretHash, secret)
} catch {
return false
}
return res.ok
}
function redirectError(
@@ -95,7 +94,7 @@ function resolveSessionUser(
app: FastifyInstance,
request: FastifyRequest,
): UserRow | undefined {
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
const raw = cookieValue(request.headers.cookie, REFRESH_COOKIE)
if (!raw) return undefined
return getUserByRefreshToken(app.db, raw)
}
@@ -172,9 +171,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
return buildJwks(app)
})
app.get('/oauth/authorize', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
app.get('/oauth/authorize', async (request, reply) => {
const q = request.query as Record<string, string | undefined>
const clientId = q.client_id
const redirectUri = q.redirect_uri
@@ -342,7 +339,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
error_description: 'Unknown or disabled client',
})
}
const secretOk = await authenticateClient(app, client, clientSecret)
const secretOk = await authenticateClient(client, clientSecret)
if (!secretOk) {
return reply.status(401).send({
error: 'invalid_client',
@@ -423,9 +420,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
},
})
app.get('/oauth/userinfo', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
app.get('/oauth/userinfo', async (request, reply) => {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
return reply.status(401).send({
-21
View File
@@ -2,7 +2,6 @@ import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildApp } from './app.js'
import { loadConfig } from './config.js'
import { registerShutdown } from './lib/shutdown.js'
for (const path of [
resolve(import.meta.dirname, '../../../.env'),
@@ -30,28 +29,8 @@ for (const path of [
}
const config = loadConfig()
// `node dist/server.js backup` — разовый онлайн-бэкап без запуска сервера
// (для cron / `docker exec auth-portal node dist/server.js backup`).
if (process.argv[2] === 'backup') {
const { createDb, closeDb } = await import('@authportal/db')
const { runBackup } = await import('./services/backup.js')
const { sqlite } = createDb(config.databaseUrl)
try {
const res = await runBackup(sqlite, config)
console.log(`backup ok: ${res.file} (removed ${res.removed.length} old)`)
closeDb(sqlite)
process.exit(0)
} catch (err) {
console.error('backup failed:', err)
process.exit(1)
}
}
const app = await buildApp({ config })
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(`auth-portal listening on ${config.serverPort}`)
-43
View File
@@ -1,43 +0,0 @@
import { dirname, join, resolve } from 'node:path'
import type { FastifyInstance } from 'fastify'
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
import type { AppConfig } from '../config.js'
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
if (config.backupDir) return resolve(config.backupDir)
const path = resolveDatabasePath(databaseUrl)
return join(dirname(resolve(path)), 'backups')
}
export async function runBackup(
sqlite: Sqlite,
config: AppConfig,
databaseUrl = config.databaseUrl,
) {
return backupDatabase(sqlite, {
dir: backupDirFor(config, databaseUrl),
keep: config.backupKeep,
})
}
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
export function startBackupJob(app: FastifyInstance): () => void {
const hours = app.config.backupIntervalHours
if (hours <= 0 || app.sqlite.memory) return () => {}
let running = false
const run = async () => {
if (running) return
running = true
try {
const res = await runBackup(app.sqlite, app.config)
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
} catch (err) {
app.log.error({ err }, 'db backup failed')
} finally {
running = false
}
}
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
interval.unref()
return () => clearInterval(interval)
}
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more