Init
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
|
user-invocable: false
|
||||||
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
---
|
||||||
|
|
||||||
|
> **ReUI skill version `14469843e9`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
|
# ReUI for Agents
|
||||||
|
|
||||||
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
Free items (the 20 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; Motion Icons and templates) 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`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 20 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/...`) and templates need Ultimate.
|
||||||
|
|
||||||
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
The 20 ReUI building blocks: `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`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's 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). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||||
|
- 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 uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
// add sorting/pagination/selection models per the API
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||||
|
|
||||||
|
## 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}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<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.
|
||||||
|
|
||||||
|
## 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:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [filters, setFilters] = useState<Filter[]>([
|
||||||
|
createFilter("priority", "is_any_of", ["low"]),
|
||||||
|
])
|
||||||
|
const fields: FilterFieldConfig[] = [
|
||||||
|
{ key: "priority", label: "Priority", type: "multiselect",
|
||||||
|
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## 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/ui/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).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- 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"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a 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.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## 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), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
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 hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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 standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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 20 ReUI building blocks 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 (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. 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.
|
||||||
|
|
||||||
|
## 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 adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); 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"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 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 full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **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. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), 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 `useReactTable` 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 semantic tokens and the active theme - no raw colors (see [styling.md](./styling.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, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. 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 (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). 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. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`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.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## 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`, `features`, `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.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## Error playbook
|
||||||
|
|
||||||
|
- **401** - the MCP requires a signed-in ReUI account. 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) as `Authorization: Bearer`.
|
||||||
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||||
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. 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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
|
user-invocable: false
|
||||||
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
---
|
||||||
|
|
||||||
|
> **ReUI skill version `14469843e9`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
|
# ReUI for Agents
|
||||||
|
|
||||||
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
Free items (the 20 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; Motion Icons and templates) 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`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 20 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/...`) and templates need Ultimate.
|
||||||
|
|
||||||
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
The 20 ReUI building blocks: `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`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's 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). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||||
|
- 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 uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
// add sorting/pagination/selection models per the API
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||||
|
|
||||||
|
## 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}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<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.
|
||||||
|
|
||||||
|
## 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:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [filters, setFilters] = useState<Filter[]>([
|
||||||
|
createFilter("priority", "is_any_of", ["low"]),
|
||||||
|
])
|
||||||
|
const fields: FilterFieldConfig[] = [
|
||||||
|
{ key: "priority", label: "Priority", type: "multiselect",
|
||||||
|
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## 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/ui/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).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- 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"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a 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.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## 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), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
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 hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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 standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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 20 ReUI building blocks 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 (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. 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.
|
||||||
|
|
||||||
|
## 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 adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); 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"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 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 full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **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. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), 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 `useReactTable` 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 semantic tokens and the active theme - no raw colors (see [styling.md](./styling.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, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. 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 (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). 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. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`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.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## 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`, `features`, `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.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## Error playbook
|
||||||
|
|
||||||
|
- **401** - the MCP requires a signed-in ReUI account. 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) as `Authorization: Bearer`.
|
||||||
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||||
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. 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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"reui": {
|
||||||
|
"url": "https://mcp.reui.io/api/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
description: Concise AI assistant — clean code, token efficiency, codebase alignment
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Coding Assistant
|
||||||
|
|
||||||
|
You work inside a real codebase. Be precise, concise, and aligned with existing patterns.
|
||||||
|
|
||||||
|
## Clean Code
|
||||||
|
|
||||||
|
- Minimal, readable, maintainable code; simple over clever
|
||||||
|
- Meaningful names; DRY; small single-responsibility functions
|
||||||
|
- Follow existing project style and patterns
|
||||||
|
|
||||||
|
## Token Efficiency
|
||||||
|
|
||||||
|
- Do not explain obvious things
|
||||||
|
- No step-by-step reasoning unless explicitly asked
|
||||||
|
- Output only what is necessary: code, brief comments when needed
|
||||||
|
- No long prose, summaries, or repetition
|
||||||
|
- If unsure — ask a short clarifying question instead of guessing
|
||||||
|
|
||||||
|
## Work With Existing Codebase
|
||||||
|
|
||||||
|
- Analyze surrounding code before generating new code
|
||||||
|
- Reuse existing utilities, helpers, and patterns
|
||||||
|
- Do not reinvent functionality already in the project
|
||||||
|
- Respect project architecture
|
||||||
|
|
||||||
|
## Documentation Awareness
|
||||||
|
|
||||||
|
- Check project docs, README, comments, and types before implementing
|
||||||
|
- If behavior is unclear: infer from types/tests/examples, or ask
|
||||||
|
- Prefer documented approaches over assumptions
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
- Default: only code
|
||||||
|
- If explanation is required — keep it under 3–5 lines
|
||||||
|
- Highlight only important decisions
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Preserve behavior unless told otherwise
|
||||||
|
- Improve readability and structure; reduce complexity and duplication
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
- Identify root cause, not symptoms
|
||||||
|
- Suggest minimal fix; avoid rewriting large parts unless necessary
|
||||||
|
|
||||||
|
## Missing Context
|
||||||
|
|
||||||
|
- Ask concise, targeted questions
|
||||||
|
- Do not hallucinate APIs or project structure
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: Conventional commits на русском языке
|
||||||
|
globs: "**/*"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commit messages (русский)
|
||||||
|
|
||||||
|
Формат: `<type>[optional scope]: <описание>`
|
||||||
|
|
||||||
|
## Типы
|
||||||
|
|
||||||
|
- `feat` — только новая UX-фича для пользователя
|
||||||
|
- `fix` — исправление бага
|
||||||
|
- `chore` — конфиг, зависимости, правила, CI
|
||||||
|
- `refactor` — рефакторинг без изменения поведения
|
||||||
|
- `docs` — документация
|
||||||
|
- `test` — тесты
|
||||||
|
- `perf` — производительность
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- Subject в **императиве**, без точки в конце
|
||||||
|
- Subject и body — **на русском**
|
||||||
|
- Body (опционально) — что и зачем, не как
|
||||||
|
- Scope в скобках при необходимости: `feat(domains): добавить фильтр по статусу`
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(frontend): заменить raw table на shadcn Table на странице доменов
|
||||||
|
|
||||||
|
feat(certificates): добавить предупреждение об истечении срока
|
||||||
|
|
||||||
|
chore(rules): консолидировать правила shadcn/ui для Cursor
|
||||||
|
```
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
description: shadcn/ui Monorepo — структура apps/web + packages/ui, CLI workflow, импорты @telemt/ui
|
||||||
|
globs: apps/web/**/*,packages/ui/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Monorepo (shadcn/ui + ReUI)
|
||||||
|
|
||||||
|
**Обязательный стандарт структуры** — [Monorepo docs](https://ui.shadcn.com/docs/monorepo), ReUI — [Get Started](https://reui.io/docs/get-started).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/ # Vite SPA (routes, queries, domain components)
|
||||||
|
apps/api/ # Fastify API + static SPA in prod
|
||||||
|
packages/ui/ # @telemt/ui — shadcn primitives
|
||||||
|
packages/shared/ # @telemt/shared — Zod schemas, parse-fqdn
|
||||||
|
packages/db/ # @telemt/db — Drizzle schema, repositories
|
||||||
|
```
|
||||||
|
|
||||||
|
## Два components.json
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| [`apps/web/components.json`](apps/web/components.json) | App aliases; `ui` → `@telemt/ui/components` |
|
||||||
|
| [`packages/ui/components.json`](packages/ui/components.json) | UI package aliases |
|
||||||
|
|
||||||
|
**Синхронизировать:** `style`, `iconLibrary`, `baseColor`, `registries` в обоих файлах.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI — только из apps/web
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
pnpm dlx shadcn@latest docs button
|
||||||
|
pnpm dlx shadcn@latest add button
|
||||||
|
pnpm dlx shadcn@latest add sidebar-07
|
||||||
|
pnpm dlx shadcn@latest add login-03
|
||||||
|
pnpm dlx shadcn@latest add @reui/data-grid
|
||||||
|
pnpm dlx shadcn@latest add @reui/filters
|
||||||
|
pnpm dlx shadcn@latest apply b2fA --only theme -y
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед обновлением существующих компонентов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dlx shadcn@latest add button --dry-run
|
||||||
|
pnpm dlx shadcn@latest add button --diff
|
||||||
|
pnpm dlx shadcn@latest info --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Куда CLI кладёт файлы
|
||||||
|
|
||||||
|
| Команда | Куда |
|
||||||
|
|---------|------|
|
||||||
|
| `add button` | `packages/ui/src/components/button.tsx` |
|
||||||
|
| `add login-03` | примитивы → `packages/ui`, block → `apps/web/src/components/` |
|
||||||
|
| `add @reui/data-grid` | `apps/web/src/components/reui/data-grid/` |
|
||||||
|
| `add @reui/filters` | `apps/web/src/components/reui/filters.tsx` |
|
||||||
|
|
||||||
|
## Импорты
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@telemt/ui/components/button'
|
||||||
|
import { cn } from '@telemt/ui/lib/utils'
|
||||||
|
import { useIsMobile } from '@telemt/ui/hooks/use-mobile'
|
||||||
|
import '@telemt/ui/globals.css' // только в main.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
| Запрещено | Разрешено |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `@/components/ui/*` | `@telemt/ui/components/*` |
|
||||||
|
| `@/components/reui/*` в `packages/ui` | `apps/web/src/components/reui/` |
|
||||||
|
| `apps/web/src/components/ui/` | `packages/ui/src/components/` |
|
||||||
|
| Ручное редактирование `globals.css` | `pnpm dlx shadcn@latest apply b2fA --only theme` |
|
||||||
|
|
||||||
|
**Registry imports после add:**
|
||||||
|
- shadcn community → переписывать на `@telemt/ui/...`
|
||||||
|
- ReUI `@reui/*` → остаётся `@/components/reui/...`; shadcn-примитивы внутри ReUI → `@telemt/ui/...`
|
||||||
|
|
||||||
|
**Post-add checklist ReUI:**
|
||||||
|
- [ ] Импорты `@/components/ui/*` → `@telemt/ui/components/*`
|
||||||
|
- [ ] Зависимости в `apps/web/package.json` (не в `packages/ui`)
|
||||||
|
- [ ] `pnpm --filter web build`
|
||||||
|
|
||||||
|
## Разделение ответственности
|
||||||
|
|
||||||
|
- **`packages/ui`** — только output `shadcn add` (примитивы, registry hooks, `cn`)
|
||||||
|
- **`apps/web/src/components/reui`** — только output `shadcn add @reui/*` (см. [`reui-mcp.mdc`](reui-mcp.mdc))
|
||||||
|
- **`apps/web/src/components`** — blocks, layout, domain (`login-form`, `app-shell`, `reui-kit/*`)
|
||||||
|
|
||||||
|
## Стили (Tailwind v4 monorepo)
|
||||||
|
|
||||||
|
`packages/ui/src/styles/globals.css` — единственный CSS-файл. **Обязательно** `@source` для обоих workspace:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@source "../"; /* packages/ui/src */
|
||||||
|
@source "../../../apps/web/src"; /* apps/web/src */
|
||||||
|
```
|
||||||
|
|
||||||
|
Без `@source` Tailwind не видит классы из `packages/ui` и `apps/web` — UI ломается (нет sidebar, card, и т.д.).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter web dev
|
||||||
|
pnpm --filter web build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] Два `components.json` согласованы (включая `@reui` registry)
|
||||||
|
- [ ] `shadcn add` из `apps/web`
|
||||||
|
- [ ] shadcn → `@telemt/ui`; ReUI → `@/components/reui`
|
||||||
|
- [ ] Нет `apps/web/src/components/ui/`
|
||||||
|
- [ ] `pnpm --filter web build` без ошибок
|
||||||
|
|
||||||
|
См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc).
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
description: Frontend — shadcn primitives + ReUI PRO blocks; CLI-first; kit patterns
|
||||||
|
globs: apps/web/**/*,packages/ui/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend — shadcn/ui + ReUI PRO
|
||||||
|
|
||||||
|
**Иерархия:** ReUI PRO > kit > shadcn primitives. См. [`reui-pro-priority.mdc`](reui-pro-priority.mdc).
|
||||||
|
|
||||||
|
**Источник истины:** MCP **`user-reui`** (pages/blocks/KPI/settings) + MCP `plugin-shadcn-shadcn` (только primitives) + docs. Не выдумывать UI. Не поднимать ui.shadcn.com/blocks выше `@reui`.
|
||||||
|
|
||||||
|
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` |
|
||||||
|
| `KanbanBoard` | `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. `@telemt/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/*` | `@telemt/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).
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
## Иерархия компонентов
|
||||||
|
|
||||||
|
```
|
||||||
|
@telemt/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 + KanbanBoardSkeleton
|
||||||
|
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` / `KanbanBoard` | `?view=board` на `/groups`, `/services` |
|
||||||
|
| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | 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` / `KanbanBoard.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 | `KanbanBoard` 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 (telemt-panel / 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 / `KanbanBoardSkeleton` / `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`
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# KPI hybrid — только kit (stats-12 DNA)
|
||||||
|
|
||||||
|
Preview: [stats-12](https://reui.io/preview/base/stats-12). 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 `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
||||||
|
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
|
||||||
|
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
||||||
|
|
||||||
|
Импорты UI: `@telemt/ui/components/*`.
|
||||||
|
|
||||||
|
## NEVER
|
||||||
|
|
||||||
|
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||||
|
- Другой size / radius / solid brand fill вместо `bg-muted`
|
||||||
|
- `card-35` как замена stats-12 hybrid KPI
|
||||||
|
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||||
|
- Голый lucide `size-4` в name-cell без hybrid tile
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
description: Структура telemt-panel — pnpm monorepo (apps/web, apps/api, packages/ui, packages/shared, packages/db)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Структура проекта telemt-panel
|
||||||
|
|
||||||
|
pnpm workspaces monorepo. Frontend — shadcn/ui + ReUI (`@reui`) + TanStack Router/Query + TS. Backend — Fastify + Drizzle + better-sqlite3 + TS.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
telemt-panel/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # Vite SPA (TSX) — TanStack Router + Query, shadcn/ui + ReUI
|
||||||
|
│ └── api/ # Fastify 5 API (TS) — @fastify/* + Drizzle
|
||||||
|
├── packages/
|
||||||
|
│ ├── ui/ # @telemt/ui — shadcn primitives (output `shadcn add`)
|
||||||
|
│ ├── shared/ # @telemt/shared — Zod-схемы контрактов, общие типы
|
||||||
|
│ └── db/ # @telemt/db — Drizzle schema, repositories, миграции
|
||||||
|
├── data/ # SQLite база (том Docker, gitignored)
|
||||||
|
├── pnpm-workspace.yaml
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.base.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## apps/web
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/
|
||||||
|
├── components.json # ui alias → @telemt/ui/components
|
||||||
|
├── vite.config.ts # React + TanStack Router plugin, proxy /api → apps/api
|
||||||
|
├── tsconfig.json
|
||||||
|
└── src/
|
||||||
|
├── main.tsx # QueryClientProvider, createRouter, import '@telemt/ui/globals.css'
|
||||||
|
├── routes/ # file-based routes (__root.tsx, _auth/...)
|
||||||
|
├── queries/ # queryOptions + key factories по сущностям
|
||||||
|
├── components/ # shared + layout + domain (blocks)
|
||||||
|
└── lib/ # api-client, queryClient, router, schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
## apps/api
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api/
|
||||||
|
└── src/
|
||||||
|
├── index.ts # buildApp()
|
||||||
|
├── config.ts # env через Zod
|
||||||
|
├── routes/ # тонкие Fastify plugins
|
||||||
|
├── services/ # бизнес-логика + адаптеры (billmanager/*)
|
||||||
|
└── plugins/ # @fastify/* registration
|
||||||
|
```
|
||||||
|
|
||||||
|
## packages/db
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/db/src/
|
||||||
|
├── schema/ # Drizzle tables по сущностям
|
||||||
|
├── repositories/ # typed queries (inArray, JOIN, transaction)
|
||||||
|
└── migrations/ # drizzle-kit generate/migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Именование
|
||||||
|
|
||||||
|
- Файлы: kebab-case (`provider-accounts.ts`, `row-mappers.ts`)
|
||||||
|
- Компоненты: PascalCase (`PageHeader.tsx`)
|
||||||
|
- Роуты API: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId`
|
||||||
|
- ID записей: `vps-bm-{accountId}-{externalId}`, `pay-bm-{accountId}-{externalId}`
|
||||||
|
|
||||||
|
## Barrel exports
|
||||||
|
|
||||||
|
- `packages/ui` — `@telemt/ui/components/*`, `@telemt/ui/lib/utils`, `@telemt/ui/hooks/*`, `@telemt/ui/globals.css`
|
||||||
|
- `packages/shared` — `@telemt/shared/contracts/*` (Zod), `@telemt/shared/types/*`
|
||||||
|
- `packages/db` — `@telemt/db/schema`, `@telemt/db/repositories/*`
|
||||||
|
- `apps/api/src/services/billmanager/index.ts` — `testConnection`, `syncFromBillmanager`, `fetchDashboardInfo`
|
||||||
|
|
||||||
|
## Скоупы правил
|
||||||
|
|
||||||
|
- Frontend (`apps/web`, `packages/ui`) — [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc)
|
||||||
|
- Backend (`apps/api`, `packages/db`, `packages/shared`) — [`backend-fastify.mdc`](backend-fastify.mdc), [`backend-drizzle.mdc`](backend-drizzle.mdc), [`backend-mcp.mdc`](backend-mcp.mdc), [`backend-testing.mdc`](backend-testing.mdc), [`backend-api-ui.mdc`](backend-api-ui.mdc), [`sqlite.mdc`](sqlite.mdc)
|
||||||
|
- API + UI-связка — [`backend-api-ui.mdc`](backend-api-ui.mdc)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# ReUI MCP — обязательно (PRO + free)
|
||||||
|
|
||||||
|
**Приоритет:** ReUI PRO **выше** голого shadcn. См. [`reui-pro-priority.mdc`](reui-pro-priority.mdc).
|
||||||
|
|
||||||
|
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
|
||||||
|
|
||||||
|
Связанные: [`reui-pro-priority.mdc`](reui-pro-priority.mdc), [`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 |
|
||||||
|
|----------|-----|
|
||||||
|
| **llms.txt** | https://reui.io/llms.txt |
|
||||||
|
| **Get Started** | https://reui.io/docs/get-started |
|
||||||
|
| **Styling** | https://reui.io/docs/styling |
|
||||||
|
| **MCP** | https://reui.io/docs/mcp |
|
||||||
|
| **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-зоны.
|
||||||
|
|
||||||
|
## Когда ReUI vs shadcn
|
||||||
|
|
||||||
|
| Задача | Registry | Импорт |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@telemt/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 | `@reui` | `@/components/reui/*` |
|
||||||
|
|
||||||
|
**Сложные списки** — `ResourcePage` (с Filters) или `FrameDataGrid` (простой CRUD Frame+DataGrid); не raw `<table>`, не Card shell.
|
||||||
|
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
|
||||||
|
**KPI** — только `KpiStatGrid` (EvoBGP hybrid); не SectionCards.
|
||||||
|
|
||||||
|
## 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 → `@telemt/ui/components/*`
|
||||||
|
6. Adapt by reuse → kit / route
|
||||||
|
7. `validate_usage` + `get_audit_checklist`
|
||||||
|
|
||||||
|
## Размещение
|
||||||
|
|
||||||
|
| Слой | Путь | Импорт |
|
||||||
|
|------|------|--------|
|
||||||
|
| shadcn | `packages/ui/src/components/` | `@telemt/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`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
||||||
|
|
||||||
|
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
||||||
|
|
||||||
|
**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` / импорт как `@telemt/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` / `@telemt/ui` — правильный слой
|
||||||
|
- [ ] `pnpm --filter web build`
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
description: ReUI PRO приоритетнее голого shadcn — иерархия UI для telemtPanel
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# ReUI PRO > голый shadcn
|
||||||
|
|
||||||
|
В **telemtPanel** UI строится **reuse-first** из ReUI PRO. Голый shadcn/ui — только primitives и токены, никогда как источник страниц/blocks.
|
||||||
|
|
||||||
|
## Иерархия (жёстко)
|
||||||
|
|
||||||
|
1. **ReUI PRO** — MCP `user-reui` + registry `@reui` + kit `reui-kit/`
|
||||||
|
pages, KPI, lists, settings, shell, empty, forms-as-blocks, Quick Actions, auth
|
||||||
|
2. **shadcn primitives** — MCP `plugin-shadcn-shadcn` + `@telemt/ui`
|
||||||
|
только Button / Field / Sidebar / Dialog / Sheet / Input / … и design tokens
|
||||||
|
3. **Kit** (`apps/web/src/components/reui-kit/`) — adapt ReUI, не redesign
|
||||||
|
4. **Domain** — wiring данных Telemt / fleet
|
||||||
|
|
||||||
|
## Primary MCP
|
||||||
|
|
||||||
|
1. **`user-reui`** — `search` → `compose_page` / `get_block` / `get_component` → `get_install_command` → CLI → `validate_usage`
|
||||||
|
2. **`plugin-shadcn-shadcn`** — только free shadcn primitives / registry sync, **не** вместо PRO blocks
|
||||||
|
|
||||||
|
Skill: `.cursor/skills/reui/SKILL.md` · docs: [Cursor](https://reui.io/docs/cursor) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks)
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
Ops / dashboard / list / detail / settings: **`surface: frame`** (`apps/web/src/lib/ui-surface.ts`).
|
||||||
|
Не смешивать Card и Frame на одном ops-экране.
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
|
||||||
|
- Поднимать **ui.shadcn.com/blocks** / shadcn MCP выше ReUI PRO для ops-экранов
|
||||||
|
- Hand-roll data-grid / KPI / Quick Actions / settings rows / empty / auth page при наличии `@reui` / kit
|
||||||
|
- Ставить SectionCards / DataGridCard / raw `<table>` как эталон
|
||||||
|
- `space-y-*` / `space-x-*` — только `flex` + `gap-*`
|
||||||
|
- Raw `bg-emerald-*` вместо semantic / ReUI `variant`
|
||||||
|
- Копипаст markup с сайта без `pnpm dlx shadcn@latest add @reui/...`
|
||||||
|
|
||||||
|
## Обязательные ссылки в ответах по UI
|
||||||
|
|
||||||
|
Для затронутой зоны всегда: `previewUrl` + `docsUrl` (например [stats-12](https://reui.io/preview/base/stats-12), [app-shell-12](https://reui.io/preview/base/app-shell-12)).
|
||||||
|
|
||||||
|
Детали: [`reui-mcp.mdc`](reui-mcp.mdc), [`kpi-hybrid.mdc`](kpi-hybrid.mdc), `docs/ui-design-contract.md`, `AGENTS.md`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: ReUI skill pointer — workflow в .cursor/skills/reui; PRO выше голого shadcn
|
||||||
|
globs: ["**/*.tsx","**/*.ts"]
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# ReUI (skill + PRO priority)
|
||||||
|
|
||||||
|
Полный workflow skill: [`.cursor/skills/reui/SKILL.md`](../skills/reui/SKILL.md).
|
||||||
|
|
||||||
|
**Приоритет UI:** ReUI PRO (`@reui` + MCP `user-reui`) → kit `reui-kit/` → shadcn primitives `@telemt/ui`.
|
||||||
|
Голый shadcn/ui blocks **не** primary. См. [`reui-pro-priority.mdc`](reui-pro-priority.mdc), [`reui-mcp.mdc`](reui-mcp.mdc).
|
||||||
|
|
||||||
|
Краткий loop:
|
||||||
|
|
||||||
|
1. `user-reui` `search` (`surface: "frame"`) — cite `previewUrl` + `docsUrl`
|
||||||
|
2. `get_install_command` → `pnpm dlx shadcn@latest add @reui/<name> --yes` из `apps/web`
|
||||||
|
3. `get_component` / examples — реальный API
|
||||||
|
4. Adapt into kit / route — не redesign
|
||||||
|
5. `validate_usage` / `get_audit_checklist`
|
||||||
|
|
||||||
|
License: `REUI_LICENSE_KEY` в `.env.local` · [license-setup](https://reui.io/docs/license-setup).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: shadcn MCP — primitives secondary; pages/blocks → user-reui first
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# shadcn MCP — primitives (secondary)
|
||||||
|
|
||||||
|
**Не primary.** Иерархия: ReUI PRO → kit → shadcn primitives. См. [`reui-pro-priority.mdc`](reui-pro-priority.mdc).
|
||||||
|
|
||||||
|
Перед UI-задачей: **сначала** MCP `user-reui` для pages / KPI / lists / settings / Frame / auth / empty ([`reui-mcp.mdc`](reui-mcp.mdc)).
|
||||||
|
MCP `plugin-shadcn-shadcn` — **только** primitives `@shadcn` / `@telemt/ui` и registry sync. **Запрещено** брать ui.shadcn.com/blocks вместо `@reui` PRO.
|
||||||
|
|
||||||
|
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)) |
|
||||||
|
| Нет примитива в `@telemt/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).
|
||||||
@@ -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` (`@telemt/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).
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
description: Vite + TanStack Router v1 + TanStack Query v5 — routing, loaders, queries, mutations
|
||||||
|
globs: apps/web/**/*.{tsx,ts}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vite + TanStack Router + Query
|
||||||
|
|
||||||
|
Фронтенд: **Vite SPA**, не Next.js. Нет Server Components, App Router, `'use client'`.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/src/
|
||||||
|
routes/ # file-based routes (__root.tsx, _auth/, ...)
|
||||||
|
queries/ # queryOptions factories + key factories
|
||||||
|
lib/ # api-client, queryClient, auth, schemas
|
||||||
|
components/ # domain + layout (UI primitives → @telemt/ui)
|
||||||
|
main.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
- **Router** — маршрутизация, URL state, navigation, loaders
|
||||||
|
- **Query** — server state, cache, mutations
|
||||||
|
- **Loader** — `queryClient.ensureQueryData()` до рендера → без спиннеров на route data
|
||||||
|
- **Компоненты** — UI; данные из Query cache
|
||||||
|
|
||||||
|
## QueryClient + Router
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lib/queryClient.ts
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { staleTime: 60_000 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// lib/router.ts
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register { router: typeof router }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query definitions
|
||||||
|
|
||||||
|
- `queryOptions` factories в `queries/`, не inline в компонентах
|
||||||
|
- Key factories: `all` → `lists` / `details` → `list(filters)` / `detail(id)`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const serviceKeys = {
|
||||||
|
all: ['services'] as const,
|
||||||
|
list: () => [...serviceKeys.all, 'list'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const servicesQueryOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: serviceKeys.list(),
|
||||||
|
queryFn: () => api.get('/api/v1/services'),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loader + component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/_auth/services')({
|
||||||
|
loader: ({ context: { queryClient } }) =>
|
||||||
|
queryClient.ensureQueryData(servicesQueryOptions()),
|
||||||
|
component: ServicesPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ServicesPage() {
|
||||||
|
const { data } = useQuery(servicesQueryOptions()) // из cache loader
|
||||||
|
return ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search params
|
||||||
|
|
||||||
|
- Zod + `validateSearch`; доступ через `Route.useSearch()`
|
||||||
|
- Search params = source of truth для фильтров/пагинации
|
||||||
|
- Передавать в `queryOptions` для query key и fetcher
|
||||||
|
|
||||||
|
## Mutations
|
||||||
|
|
||||||
|
```ts
|
||||||
|
onSuccess: (newItem) => {
|
||||||
|
queryClient.setQueryData(keys.detail(newItem.id), newItem)
|
||||||
|
queryClient.invalidateQueries({ queryKey: keys.lists() })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `setQueryData` + `invalidateQueries`, не только invalidate
|
||||||
|
- Навигация после create — когда cache уже тёплый
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
- `createFileRoute` для file-based routes
|
||||||
|
- `<Link>` для внутренней навигации, не `<a href>`
|
||||||
|
- Pathless layouts: `_auth/` для protected routes
|
||||||
|
- Auth guard в `beforeLoad` pathless route
|
||||||
|
|
||||||
|
## Запреты
|
||||||
|
|
||||||
|
- `useEffect` для fetch данных — только loader / `useQuery`
|
||||||
|
- Inline `queryKey` в компонентах — только factories из `queries/`
|
||||||
|
- `useQuery` с позиционными аргументами (v5 — только options object)
|
||||||
|
- `window.location` для search params
|
||||||
|
|
||||||
|
## Prefetch
|
||||||
|
|
||||||
|
`onMouseEnter` на `<Link>` → `queryClient.prefetchQuery(detailOptions(id))`
|
||||||
|
|
||||||
|
## DevTools
|
||||||
|
|
||||||
|
Только в dev: `TanStackRouterDevtools`, `ReactQueryDevtools`
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
|
user-invocable: false
|
||||||
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
---
|
||||||
|
|
||||||
|
> **ReUI skill version `14469843e9`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
|
# ReUI for Agents
|
||||||
|
|
||||||
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
Free items (the 20 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; Motion Icons and templates) 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`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 20 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/...`) and templates need Ultimate.
|
||||||
|
|
||||||
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
The 20 ReUI building blocks: `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`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's 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). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||||
|
- 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 uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
// add sorting/pagination/selection models per the API
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||||
|
|
||||||
|
## 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}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<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.
|
||||||
|
|
||||||
|
## 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:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [filters, setFilters] = useState<Filter[]>([
|
||||||
|
createFilter("priority", "is_any_of", ["low"]),
|
||||||
|
])
|
||||||
|
const fields: FilterFieldConfig[] = [
|
||||||
|
{ key: "priority", label: "Priority", type: "multiselect",
|
||||||
|
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## 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/ui/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).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- 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"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a 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.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## 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), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
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 hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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 standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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 20 ReUI building blocks 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 (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. 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.
|
||||||
|
|
||||||
|
## 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 adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); 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"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 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 full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **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. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), 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 `useReactTable` 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 semantic tokens and the active theme - no raw colors (see [styling.md](./styling.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, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. 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 (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). 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. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`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.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## 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`, `features`, `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.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## Error playbook
|
||||||
|
|
||||||
|
- **401** - the MCP requires a signed-in ReUI account. 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) as `Authorization: Bearer`.
|
||||||
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||||
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. 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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# ReUI license (web)
|
||||||
|
REUI_LICENSE_KEY=
|
||||||
|
|
||||||
|
# API / Docker
|
||||||
|
PANEL_MODE=standalone
|
||||||
|
TELEMT_API_URL=http://127.0.0.1:9091
|
||||||
|
TELEMT_AUTH_HEADER=
|
||||||
|
JWT_SECRET=dev-secret-change-me-please
|
||||||
|
JWT_TTL_HOURS=24
|
||||||
|
PANEL_ENCRYPTION_KEY=dev-encryption-key-change-me
|
||||||
|
PANEL_PUBLIC_URL=http://127.0.0.1:8080
|
||||||
|
BOOTSTRAP_USERNAME=admin
|
||||||
|
BOOTSTRAP_PASSWORD=admin
|
||||||
|
DATABASE_URL=sqlite:data/app.db
|
||||||
|
SERVER_PORT=8080
|
||||||
|
LOG_LEVEL=info
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
name: Build and Push Telemt Panel Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
|
||||||
|
tags: ['v*']
|
||||||
|
paths: ['**']
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
paths: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.shts.su
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
|
- name: Create version file
|
||||||
|
run: |
|
||||||
|
VERSION=$(cat VERSION)
|
||||||
|
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
BRANCH="${{ gitea.ref_name }}"
|
||||||
|
echo "APP_VERSION=${VERSION}" > ./version.txt
|
||||||
|
echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt
|
||||||
|
echo "GIT_BRANCH=${BRANCH}" >> ./version.txt
|
||||||
|
echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt
|
||||||
|
echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt
|
||||||
|
echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.shts.su/${{ gitea.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
|
||||||
|
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
NODE_VERSION=22.23.0-bookworm-slim
|
||||||
|
cache-from: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache
|
||||||
|
cache-to: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache,mode=max
|
||||||
|
provenance: true
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
needs: build-and-push
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||||
|
if [[ -n "$LAST_TAG" ]]; then
|
||||||
|
CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges ${LAST_TAG}..HEAD 2>/dev/null || echo "")
|
||||||
|
else
|
||||||
|
CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges -10 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
if [[ -z "$CHANGELOG" ]]; then CHANGELOG="- No changes detected"; fi
|
||||||
|
echo "CHANGELOG<<EOF" >> $GITEA_OUTPUT
|
||||||
|
echo "$CHANGELOG" >> $GITEA_OUTPUT
|
||||||
|
echo "EOF" >> $GITEA_OUTPUT
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
run: |
|
||||||
|
VERSION="${{ gitea.ref_name }}"
|
||||||
|
RELEASE_DATA=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"tag_name": "${VERSION}",
|
||||||
|
"target_commitish": "${{ gitea.sha }}",
|
||||||
|
"name": "telemt-panel ${VERSION}",
|
||||||
|
"body": "## telemt-panel ${VERSION}\n\n### Docker\n\n\`\`\`bash\ndocker pull git.shts.su/${{ gitea.repository }}:${VERSION}\ndocker run -d --network host -e PANEL_MODE=standalone -e TELEMT_API_URL=http://127.0.0.1:9091 -e JWT_SECRET=change-me -e BOOTSTRAP_PASSWORD=change-me -v /var/lib/telemt-panel:/data --name telemt-panel git.shts.su/${{ gitea.repository }}:${VERSION}\n\`\`\`\n\nСм. docs/install.md\n\n### Changelog\n\n${{ steps.changelog.outputs.CHANGELOG }}",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/release.json -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$RELEASE_DATA" \
|
||||||
|
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases")
|
||||||
|
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 409 ]; then
|
||||||
|
echo "Release OK (HTTP $HTTP_CODE)"
|
||||||
|
else
|
||||||
|
cat /tmp/release.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Local MCP configs (may contain REUI license Bearer)
|
||||||
|
.cursor/mcp.json
|
||||||
|
.mcp.json
|
||||||
|
.codegraph/daemon.pid
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store
|
||||||
|
coverage
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Understand Anything (knowledge graph)
|
||||||
|
.understand-anything/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Agent binaries
|
||||||
|
apps/agent/telemt-panel-agent
|
||||||
|
apps/agent/telemt-panel-agent.exe
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Local SQLite / version stamp from CI
|
||||||
|
version.txt
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
|
user-invocable: false
|
||||||
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
---
|
||||||
|
|
||||||
|
> **ReUI skill version `14469843e9`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
|
# ReUI for Agents
|
||||||
|
|
||||||
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Operational surfaces (settings / profile / admin)
|
||||||
|
|
||||||
|
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLI: registry setup, license, non-interactive install
|
||||||
|
|
||||||
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
|
Free items (the 20 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; Motion Icons and templates) 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`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 20 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/...`) and templates need Ultimate.
|
||||||
|
|
||||||
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# ReUI components
|
||||||
|
|
||||||
|
The 20 ReUI building blocks: `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`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's 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). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
|
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
|
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||||
|
- 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 uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
// add sorting/pagination/selection models per the API
|
||||||
|
})
|
||||||
|
|
||||||
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
|
||||||
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||||
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||||
|
|
||||||
|
## 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}>
|
||||||
|
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
|
||||||
|
<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.
|
||||||
|
|
||||||
|
## 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:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [filters, setFilters] = useState<Filter[]>([
|
||||||
|
createFilter("priority", "is_any_of", ["low"]),
|
||||||
|
])
|
||||||
|
const fields: FilterFieldConfig[] = [
|
||||||
|
{ key: "priority", label: "Priority", type: "multiselect",
|
||||||
|
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## 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/ui/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).
|
||||||
|
|
||||||
|
## frame
|
||||||
|
|
||||||
|
**Required:** `Frame` > `FramePanel`
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Frame>
|
||||||
|
<FramePanel>
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Title</FrameTitle>
|
||||||
|
<FrameDescription>Description</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<div className="p-5">Content</div>
|
||||||
|
<FrameFooter>Footer</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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. Defer generic alert rules to the shadcn skill.
|
||||||
|
|
||||||
|
## badge
|
||||||
|
|
||||||
|
**Required:** none (text child).
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="success-light" size="sm">Success</Badge>
|
||||||
|
<Badge variant="outline" radius="full">Pill</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
|
||||||
|
|
||||||
|
## base vs radix - write for the project's base
|
||||||
|
|
||||||
|
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
|
||||||
|
|
||||||
|
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
|
||||||
|
|
||||||
|
- `"style": "base-nova"` -> **Base UI**
|
||||||
|
- `"style": "radix-nova"` -> **Radix UI**
|
||||||
|
|
||||||
|
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
|
||||||
|
|
||||||
|
- 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"`.
|
||||||
|
|
||||||
|
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Craft: make ReUI UI exceptional, not generic
|
||||||
|
|
||||||
|
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
|
||||||
|
|
||||||
|
## Have a 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.
|
||||||
|
|
||||||
|
## Brutally clear hierarchy
|
||||||
|
|
||||||
|
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
|
||||||
|
|
||||||
|
## Spacing rhythm and deliberate density
|
||||||
|
|
||||||
|
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
|
||||||
|
|
||||||
|
## Cover the real states (the usual miss)
|
||||||
|
|
||||||
|
A surface isn't done at the happy path. Compose, and wire:
|
||||||
|
|
||||||
|
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
|
||||||
|
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
|
||||||
|
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
|
||||||
|
|
||||||
|
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
|
||||||
|
|
||||||
|
## Responsive by default
|
||||||
|
|
||||||
|
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
|
||||||
|
|
||||||
|
## Motion, subtly
|
||||||
|
|
||||||
|
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
|
||||||
|
|
||||||
|
## Real, activated content
|
||||||
|
|
||||||
|
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
|
||||||
|
|
||||||
|
## Avoid the AI tells
|
||||||
|
|
||||||
|
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Icons (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
|
||||||
|
|
||||||
|
## Portable icons (library-agnostic)
|
||||||
|
|
||||||
|
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
|
||||||
|
|
||||||
|
- `lucide` -> `lucide-react`
|
||||||
|
- `tabler` -> `@tabler/icons-react`
|
||||||
|
- `phosphor` -> `@phosphor-icons/react`
|
||||||
|
- `remix` -> `@remixicon/react`
|
||||||
|
- `hugeicons` -> `@hugeicons/react`
|
||||||
|
|
||||||
|
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
|
||||||
|
|
||||||
|
## 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), each icon in two variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
|
||||||
|
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
|
||||||
|
|
||||||
|
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 hover-animated Motion variant.
|
||||||
|
- One icon: `search` with `type: "icon"`.
|
||||||
|
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
|
||||||
|
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
|
||||||
|
|
||||||
|
The `icon-stack` component composes multiple icons into a stacked display.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Quality gates (security, accessibility, scroll)
|
||||||
|
|
||||||
|
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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 standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
|
||||||
|
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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 20 ReUI building blocks 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 (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
|
|
||||||
|
## The @reui registry
|
||||||
|
|
||||||
|
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. 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.
|
||||||
|
|
||||||
|
## 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 adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
|
||||||
|
|
||||||
|
## Free vs premium
|
||||||
|
|
||||||
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Styling (ReUI delta over shadcn)
|
||||||
|
|
||||||
|
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
|
||||||
|
|
||||||
|
## ReUI extended semantic tokens
|
||||||
|
|
||||||
|
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
|
||||||
|
|
||||||
|
- `--success` / `--success-foreground`
|
||||||
|
- `--info` / `--info-foreground`
|
||||||
|
- `--warning` / `--warning-foreground`
|
||||||
|
- `--destructive-foreground` (paired with shadcn's `--destructive`)
|
||||||
|
- `--invert` / `--invert-foreground` (inverted surfaces)
|
||||||
|
|
||||||
|
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
|
||||||
|
|
||||||
|
**Incorrect:** `<span className="text-green-600">Active</span>`
|
||||||
|
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
|
||||||
|
|
||||||
|
## Blocks follow your theme
|
||||||
|
|
||||||
|
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
|
||||||
|
|
||||||
|
## Density and typography rhythm
|
||||||
|
|
||||||
|
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 1. Find (ReUI MCP `search` / `compose_page`)
|
||||||
|
|
||||||
|
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); 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"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
|
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
|
||||||
|
|
||||||
|
## 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 full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **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. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), 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 `useReactTable` 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 semantic tokens and the active theme - no raw colors (see [styling.md](./styling.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, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. 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 (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). 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. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
|
||||||
|
- **`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.
|
||||||
|
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
|
||||||
|
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
|
||||||
|
|
||||||
|
## All 19 tools
|
||||||
|
|
||||||
|
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
|
||||||
|
|
||||||
|
## 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`, `features`, `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.
|
||||||
|
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
|
||||||
|
|
||||||
|
## Error playbook
|
||||||
|
|
||||||
|
- **401** - the MCP requires a signed-in ReUI account. 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) as `Authorization: Bearer`.
|
||||||
|
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
|
||||||
|
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
|
||||||
|
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. 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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Telemt Panel — agent notes
|
||||||
|
|
||||||
|
## UI hierarchy (жёстко)
|
||||||
|
|
||||||
|
1. **ReUI PRO** — MCP `user-reui`, registry `@reui`, blocks/components, skill `.cursor/skills/reui`
|
||||||
|
2. **Kit** — `apps/web/src/components/reui-kit/` (adapt, не redesign)
|
||||||
|
3. **shadcn primitives** — `@telemt/ui` + MCP `plugin-shadcn-shadcn` (Button/Field/Sidebar/…)
|
||||||
|
4. **Domain** — данные Telemt / fleet
|
||||||
|
|
||||||
|
Голый shadcn/ui **blocks** и ui.shadcn.com/blocks **не** эталон для ops-экранов.
|
||||||
|
|
||||||
|
Rules: `.cursor/rules/reui-pro-priority.mdc` (always), `reui-mcp.mdc`, `kpi-hybrid.mdc`.
|
||||||
|
Contract: `docs/ui-design-contract.md`.
|
||||||
|
|
||||||
|
## UI (ReUI PRO)
|
||||||
|
|
||||||
|
- Surface: **frame** only (`apps/web/src/lib/ui-surface.ts`)
|
||||||
|
- MCP: `search` → `get_block`/`compose_page` → `get_install_command` → CLI → `validate_usage`
|
||||||
|
- Always cite `previewUrl` + `docsUrl`
|
||||||
|
- Previews: [app-shell-12](https://reui.io/preview/base/app-shell-12), [stats-12](https://reui.io/preview/base/stats-12), [settings-16](https://reui.io/preview/base/settings-16), [auth-4](https://reui.io/preview/base/auth-4)
|
||||||
|
- Kit: `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`
|
||||||
|
|
||||||
|
## License / Cursor
|
||||||
|
|
||||||
|
- [License Setup](https://reui.io/docs/license-setup) · [Cursor MCP](https://reui.io/docs/cursor)
|
||||||
|
- `REUI_LICENSE_KEY` in root `.env.local` + `apps/web/.env.local`
|
||||||
|
- `components.json` → `@reui` Bearer `${REUI_LICENSE_KEY}`
|
||||||
|
- `.cursor/mcp.json` → Bearer `${env:REUI_LICENSE_KEY}`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Plan 1: ReUI PRO scaffold (done)
|
||||||
|
- Plan 2: fleet API, local JWT auth, Go agent, Telemt `/v1` via agents
|
||||||
|
|
||||||
|
Do not mix Card and Frame on ops screens.
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
ARG NODE_VERSION=22.23.0-bookworm-slim
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION} AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& corepack enable
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY packages/ui/package.json packages/ui/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
COPY apps/web apps/web
|
||||||
|
COPY apps/api apps/api
|
||||||
|
COPY packages/ui packages/ui
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
COPY packages/db packages/db
|
||||||
|
COPY deploy deploy
|
||||||
|
|
||||||
|
RUN pnpm --filter @telemt/db build \
|
||||||
|
&& pnpm --filter @telemt/shared build \
|
||||||
|
&& pnpm --filter @telemt/web build \
|
||||||
|
&& pnpm --filter @telemt/api build \
|
||||||
|
&& pnpm --filter @telemt/api deploy --prod /out \
|
||||||
|
&& cp -r apps/web/dist /out/static \
|
||||||
|
&& mkdir -p /out/deploy \
|
||||||
|
&& cp deploy/install-agent.sh /out/deploy/ \
|
||||||
|
&& rm -rf /out/src /out/test /out/.turbo \
|
||||||
|
&& rm -rf /out/node_modules/@telemt/db/src /out/node_modules/@telemt/db/.turbo \
|
||||||
|
&& rm -rf /out/node_modules/@telemt/shared/src /out/node_modules/@telemt/shared/.turbo \
|
||||||
|
&& find /out/dist /out/node_modules/@telemt -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' \) -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
STATIC_DIR=/app/static \
|
||||||
|
DATABASE_URL=sqlite:/data/app.db \
|
||||||
|
SERVER_PORT=8080 \
|
||||||
|
PANEL_MODE=standalone
|
||||||
|
|
||||||
|
COPY --from=build /out ./
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
VOLUME ["/data"]
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Telemt Panel
|
||||||
|
|
||||||
|
Control panel for Telemt MTProxy — **standalone** (direct `/v1`) or **fleet** (Go agents).
|
||||||
|
|
||||||
|
Image: `git.shts.su/denozord/telemtPanel`
|
||||||
|
|
||||||
|
## Quick start (standalone)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.shts.su/denozord/telemtPanel:latest
|
||||||
|
docker run -d --name telemt-panel --network host \
|
||||||
|
-e PANEL_MODE=standalone \
|
||||||
|
-e TELEMT_API_URL=http://127.0.0.1:9091 \
|
||||||
|
-e JWT_SECRET=change-me-long \
|
||||||
|
-e BOOTSTRAP_PASSWORD=change-me \
|
||||||
|
-v /var/lib/telemt-panel:/data \
|
||||||
|
git.shts.su/denozord/telemtPanel:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Полная инструкция (RU): **[docs/install.md](docs/install.md)**.
|
||||||
|
|
||||||
|
Также: [telemt-control-api.md](docs/telemt-control-api.md), [agent-protocol.md](docs/agent-protocol.md).
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter @telemt/db build && pnpm --filter @telemt/shared build
|
||||||
|
pnpm dev:api # :8080
|
||||||
|
pnpm dev # Vite :5173 → proxy /api
|
||||||
|
pnpm bootstrap-admin -- --username admin --password '…'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- pnpm monorepo: `apps/web`, `apps/api`, `apps/agent`, `packages/{ui,db,shared}`
|
||||||
|
- Vite + React 19 + TanStack + Fastify + Drizzle/SQLite
|
||||||
|
- ReUI PRO (`surface: frame`) — [stats-12](https://reui.io/preview/base/stats-12), [auth-4](https://reui.io/preview/base/auth-4), [app-shell-12](https://reui.io/preview/base/app-shell-12)
|
||||||
|
|
||||||
|
## Compose
|
||||||
|
|
||||||
|
- `docker-compose.yml` — standalone (host network)
|
||||||
|
- `docker-compose.fleet.yml` — fleet control plane
|
||||||
|
- `deploy/docker-compose.traefik.yml` — prod за Traefik (`/opt/telemt-panel`)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/denozord/telemtPanel/agent
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Telemt Panel agent (fleet mode)
|
||||||
|
// Build: go build -o telemt-panel-agent .
|
||||||
|
// Run: ./telemt-panel-agent -panel-url https://panel -token <enrollment> OR -state /var/lib/...
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type enrollResponse struct {
|
||||||
|
AgentID string `json:"agentId"`
|
||||||
|
AgentToken string `json:"agentToken"`
|
||||||
|
PanelURL string `json:"panelUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type job struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type stateFile struct {
|
||||||
|
AgentID string `json:"agentId"`
|
||||||
|
AgentToken string `json:"agentToken"`
|
||||||
|
PanelURL string `json:"panelUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
panelURL := flag.String("panel-url", os.Getenv("PANEL_URL"), "panel base URL")
|
||||||
|
token := flag.String("token", os.Getenv("ENROLLMENT_TOKEN"), "enrollment token")
|
||||||
|
statePath := flag.String("state", "/var/lib/telemt-panel-agent/state.json", "state file")
|
||||||
|
telemtURL := flag.String("telemt-url", envOr("TELEMT_API_URL", "http://127.0.0.1:9091"), "local Telemt API")
|
||||||
|
telemtAuth := flag.String("telemt-auth", os.Getenv("TELEMT_AUTH_HEADER"), "Telemt Authorization header")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
st, err := loadOrEnroll(*statePath, *panelURL, *token)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
log.Printf("agent %s connected to %s", st.AgentID, st.PanelURL)
|
||||||
|
|
||||||
|
for {
|
||||||
|
jobs, err := fetchJobs(client, st)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("jobs poll: %v", err)
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, j := range jobs {
|
||||||
|
if j.Type != "telemt.proxy" {
|
||||||
|
_ = postResult(client, st, j.ID, false, nil, "unsupported job type")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := doTelemtProxy(client, *telemtURL, *telemtAuth, j.Payload)
|
||||||
|
if err != nil {
|
||||||
|
_ = postResult(client, st, j.ID, false, nil, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = postResult(client, st, j.ID, true, result, "")
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrEnroll(path, panelURL, token string) (*stateFile, error) {
|
||||||
|
if b, err := os.ReadFile(path); err == nil {
|
||||||
|
var st stateFile
|
||||||
|
if json.Unmarshal(b, &st) == nil && st.AgentToken != "" {
|
||||||
|
return &st, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if panelURL == "" || token == "" {
|
||||||
|
return nil, fmt.Errorf("need -panel-url and -token for first enroll (or existing state file)")
|
||||||
|
}
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"token": token,
|
||||||
|
"hostname": hostname,
|
||||||
|
"name": hostname,
|
||||||
|
"agentVersion": "0.1.0",
|
||||||
|
})
|
||||||
|
res, err := http.Post(panelURL+"/api/agent/enroll", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(res.Body)
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("enroll %s: %s", res.Status, string(raw))
|
||||||
|
}
|
||||||
|
var er enrollResponse
|
||||||
|
if err := json.Unmarshal(raw, &er); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st := &stateFile{AgentID: er.AgentID, AgentToken: er.AgentToken, PanelURL: er.PanelURL}
|
||||||
|
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||||
|
b, _ := json.MarshalIndent(st, "", " ")
|
||||||
|
_ = os.WriteFile(path, b, 0o600)
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchJobs(client *http.Client, st *stateFile) ([]job, error) {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, st.PanelURL+"/api/agent/jobs", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+st.AgentToken)
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(res.Body)
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("%s: %s", res.Status, string(raw))
|
||||||
|
}
|
||||||
|
var jobs []job
|
||||||
|
if err := json.Unmarshal(raw, &jobs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postResult(client *http.Client, st *stateFile, id string, ok bool, result any, errMsg string) error {
|
||||||
|
payload := map[string]any{"ok": ok, "result": result, "error": errMsg}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, st.PanelURL+"/api/agent/jobs/"+id+"/result", bytes.NewReader(b))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+st.AgentToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func doTelemtProxy(client *http.Client, base, auth string, payload map[string]any) (any, error) {
|
||||||
|
method, _ := payload["method"].(string)
|
||||||
|
if method == "" {
|
||||||
|
method = "GET"
|
||||||
|
}
|
||||||
|
path, _ := payload["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("missing path")
|
||||||
|
}
|
||||||
|
url := stringsTrimSlash(base) + path
|
||||||
|
var body io.Reader
|
||||||
|
if payload["body"] != nil && method != "GET" && method != "DELETE" {
|
||||||
|
b, _ := json.Marshal(payload["body"])
|
||||||
|
body = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, url, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if auth != "" {
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(res.Body)
|
||||||
|
var out any
|
||||||
|
if json.Unmarshal(raw, &out) != nil {
|
||||||
|
out = map[string]any{"ok": false, "raw": string(raw), "status": res.StatusCode}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsTrimSlash(s string) string {
|
||||||
|
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@telemt/api",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsup src/server.ts --format esm --dts",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"bootstrap-admin": "tsx src/scripts/bootstrap-admin.ts",
|
||||||
|
"test": "vitest run --passWithNoTests"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.0.1",
|
||||||
|
"@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",
|
||||||
|
"@telemt/db": "workspace:*",
|
||||||
|
"@telemt/shared": "workspace:*",
|
||||||
|
"drizzle-orm": "^0.44.2",
|
||||||
|
"fastify": "^5.4.0",
|
||||||
|
"fastify-plugin": "^5.0.1",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.15.32",
|
||||||
|
"tsup": "^8.5.0",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import cors from '@fastify/cors'
|
||||||
|
import helmet from '@fastify/helmet'
|
||||||
|
import rateLimit from '@fastify/rate-limit'
|
||||||
|
import sensible from '@fastify/sensible'
|
||||||
|
import fjwt from '@fastify/jwt'
|
||||||
|
import fastifyStatic from '@fastify/static'
|
||||||
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { createDb, healthCheck, migrateSchema, type AppDb, type Sqlite } from '@telemt/db'
|
||||||
|
import type { AppConfig } from './config.js'
|
||||||
|
import { TelemtClient } from './services/telemt-client.js'
|
||||||
|
import { authRoutes, ensureBootstrapAdmin } from './routes/auth.js'
|
||||||
|
import { telemtRoutes, fleetRoutes, agentProtocolRoutes } from './routes/telemt.js'
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
config: AppConfig
|
||||||
|
db: AppDb
|
||||||
|
sqlite: Sqlite
|
||||||
|
telemt: TelemtClient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(opts: {
|
||||||
|
config: AppConfig
|
||||||
|
databaseUrl?: string
|
||||||
|
}): Promise<FastifyInstance> {
|
||||||
|
const config = opts.config
|
||||||
|
const dbUrl = opts.databaseUrl ?? config.databaseUrl
|
||||||
|
|
||||||
|
const path = dbUrl.replace(/^sqlite:/, '')
|
||||||
|
if (path !== ':memory:') {
|
||||||
|
mkdirSync(dirname(resolve(path)), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db, sqlite } = createDb(dbUrl)
|
||||||
|
migrateSchema(sqlite)
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: config.logLevel },
|
||||||
|
trustProxy: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const telemt = new TelemtClient(config.telemtApiUrl, config.telemtAuthHeader)
|
||||||
|
|
||||||
|
app.decorate('config', config)
|
||||||
|
app.decorate('db', db)
|
||||||
|
app.decorate('sqlite', sqlite)
|
||||||
|
app.decorate('telemt', telemt)
|
||||||
|
|
||||||
|
await app.register(sensible)
|
||||||
|
await app.register(cors, { origin: true, credentials: true })
|
||||||
|
await app.register(helmet, { contentSecurityPolicy: false })
|
||||||
|
await app.register(rateLimit, { max: 300, timeWindow: '1 minute' })
|
||||||
|
await app.register(fjwt, { secret: config.jwtSecret })
|
||||||
|
|
||||||
|
app.get('/health', async () => {
|
||||||
|
healthCheck(sqlite)
|
||||||
|
return { ok: true, panelMode: config.panelMode }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/ready', async () => {
|
||||||
|
healthCheck(sqlite)
|
||||||
|
const telemtOk =
|
||||||
|
config.panelMode === 'standalone' ? await telemt.health() : true
|
||||||
|
return { ok: true, telemt: telemtOk, panelMode: config.panelMode }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/health', async () => ({
|
||||||
|
ok: true,
|
||||||
|
service: 'telemt-panel',
|
||||||
|
panelMode: config.panelMode,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (config.bootstrapPassword) {
|
||||||
|
await ensureBootstrapAdmin(app, config.bootstrapUsername, config.bootstrapPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.register(authRoutes)
|
||||||
|
await app.register(telemtRoutes)
|
||||||
|
await app.register(fleetRoutes)
|
||||||
|
await app.register(agentProtocolRoutes)
|
||||||
|
|
||||||
|
app.get('/install-agent.sh', async (_request, reply) => {
|
||||||
|
const candidates = [
|
||||||
|
resolve(process.cwd(), 'deploy/install-agent.sh'),
|
||||||
|
resolve(import.meta.dirname, '../../../deploy/install-agent.sh'),
|
||||||
|
'/app/deploy/install-agent.sh',
|
||||||
|
]
|
||||||
|
const scriptPath = candidates.find((p) => existsSync(p))
|
||||||
|
if (!scriptPath) {
|
||||||
|
return reply.code(404).send('install-agent.sh not found')
|
||||||
|
}
|
||||||
|
reply.header('Content-Type', 'text/x-shellscript')
|
||||||
|
return readFileSync(scriptPath, 'utf-8')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (config.staticDir && existsSync(config.staticDir)) {
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: config.staticDir,
|
||||||
|
wildcard: false,
|
||||||
|
})
|
||||||
|
app.setNotFoundHandler((request, reply) => {
|
||||||
|
if (request.method === 'GET' && !request.url.startsWith('/api')) {
|
||||||
|
return reply.sendFile('index.html')
|
||||||
|
}
|
||||||
|
return reply.code(404).send({ error: { code: 'not_found', message: 'Not found' } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { PANEL_MODES } from '@telemt/shared'
|
||||||
|
|
||||||
|
export const configSchema = z.object({
|
||||||
|
databaseUrl: z.string().default('sqlite:data/app.db'),
|
||||||
|
jwtSecret: z.string().min(8),
|
||||||
|
jwtTtlHours: z.coerce.number().positive().default(24),
|
||||||
|
issuer: z.string().default('telemt-panel'),
|
||||||
|
serverPort: z.coerce.number().int().positive().default(8080),
|
||||||
|
staticDir: z.string().optional(),
|
||||||
|
logLevel: z.string().default('info'),
|
||||||
|
isProd: z.boolean(),
|
||||||
|
panelMode: z.enum(PANEL_MODES).default('standalone'),
|
||||||
|
telemtApiUrl: z.string().default('http://127.0.0.1:9091'),
|
||||||
|
telemtAuthHeader: z.string().default(''),
|
||||||
|
panelEncryptionKey: z.string().default(''),
|
||||||
|
panelPublicUrl: z.string().default('http://127.0.0.1:8080'),
|
||||||
|
bootstrapUsername: z.string().default('admin'),
|
||||||
|
bootstrapPassword: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AppConfig = z.infer<typeof configSchema>
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
return configSchema.parse({
|
||||||
|
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||||
|
jwtSecret,
|
||||||
|
jwtTtlHours: env.JWT_TTL_HOURS ?? 24,
|
||||||
|
issuer: env.ISSUER ?? 'telemt-panel',
|
||||||
|
serverPort: env.SERVER_PORT ?? 8080,
|
||||||
|
staticDir: env.STATIC_DIR || undefined,
|
||||||
|
logLevel: env.LOG_LEVEL ?? 'info',
|
||||||
|
isProd,
|
||||||
|
panelMode: env.PANEL_MODE ?? 'standalone',
|
||||||
|
telemtApiUrl: env.TELEMT_API_URL ?? 'http://127.0.0.1:9091',
|
||||||
|
telemtAuthHeader: env.TELEMT_AUTH_HEADER ?? '',
|
||||||
|
panelEncryptionKey: env.PANEL_ENCRYPTION_KEY ?? (isProd ? '' : 'dev-encryption-key'),
|
||||||
|
panelPublicUrl: env.PANEL_PUBLIC_URL ?? 'http://127.0.0.1:8080',
|
||||||
|
bootstrapUsername: env.BOOTSTRAP_USERNAME ?? 'admin',
|
||||||
|
bootstrapPassword: env.BOOTSTRAP_PASSWORD,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { operators } from '@telemt/db'
|
||||||
|
|
||||||
|
export interface AuthOperator {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/jwt' {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: { sub: string; username: string; role: string }
|
||||||
|
user: { sub: string; username: string; role: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: { code: 'unauthorized', message: 'Требуется вход' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = request.server.db
|
||||||
|
.select()
|
||||||
|
.from(operators)
|
||||||
|
.where(eq(operators.id, request.user.sub))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!row || row.disabled) {
|
||||||
|
return reply.code(401).send({ error: { code: 'unauthorized', message: 'Оператор недоступен' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
;(request as FastifyRequest & { operator: AuthOperator }).operator = {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
role: row.role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOperator(request: FastifyRequest): AuthOperator {
|
||||||
|
return (request as FastifyRequest & { operator: AuthOperator }).operator
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { hash, verify } from '@node-rs/argon2'
|
||||||
|
import { loginRequestSchema } from '@telemt/shared'
|
||||||
|
import { operators } from '@telemt/db'
|
||||||
|
import { getOperator, requireAuth } from '../plugins/auth-guards.js'
|
||||||
|
|
||||||
|
function sha256(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
|
app.post('/api/auth/login', async (request, reply) => {
|
||||||
|
const parsed = loginRequestSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: { code: 'bad_request', message: 'Неверные данные' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = app.db
|
||||||
|
.select()
|
||||||
|
.from(operators)
|
||||||
|
.where(eq(operators.username, parsed.data.username))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!row || row.disabled) {
|
||||||
|
return reply.code(401).send({ error: { code: 'invalid_credentials', message: 'Неверный логин или пароль' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verify(row.passwordHash, parsed.data.password)
|
||||||
|
if (!ok) {
|
||||||
|
return reply.code(401).send({ error: { code: 'invalid_credentials', message: 'Неверный логин или пароль' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await reply.jwtSign(
|
||||||
|
{ sub: row.id, username: row.username, role: row.role },
|
||||||
|
{ expiresIn: `${app.config.jwtTtlHours}h` },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
operator: { id: row.id, username: row.username, role: row.role },
|
||||||
|
panelMode: app.config.panelMode,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/auth/me', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const op = getOperator(request)
|
||||||
|
return { operator: op, panelMode: app.config.panelMode }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/auth/config', async () => ({
|
||||||
|
panelMode: app.config.panelMode,
|
||||||
|
issuer: app.config.issuer,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureBootstrapAdmin(
|
||||||
|
app: FastifyInstance,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = app.db.select().from(operators).all()
|
||||||
|
if (existing.length > 0) return
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const passwordHash = await hash(password)
|
||||||
|
app.db
|
||||||
|
.insert(operators)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
role: 'admin',
|
||||||
|
disabled: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
app.log.info(`bootstrap admin created: ${username}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { sha256, randomBytes }
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { eq, and, asc, isNull } from 'drizzle-orm'
|
||||||
|
import { telemtProxyRequestSchema } from '@telemt/shared'
|
||||||
|
import { agents, jobs, enrollmentTokens, managedClients } from '@telemt/db'
|
||||||
|
import { requireAuth, getOperator } from '../plugins/auth-guards.js'
|
||||||
|
import { sha256, randomBytes } from './auth.js'
|
||||||
|
|
||||||
|
export async function telemtRoutes(app: FastifyInstance) {
|
||||||
|
app.all('/api/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
|
const suffix = (request.params as { '*': string })['*']
|
||||||
|
const path = `/v1/${suffix}`
|
||||||
|
const method = request.method.toUpperCase()
|
||||||
|
|
||||||
|
if (app.config.panelMode === 'standalone') {
|
||||||
|
const ifMatch = request.headers['if-match']
|
||||||
|
try {
|
||||||
|
const { status, envelope } = await app.telemt.request({
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
|
||||||
|
ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined,
|
||||||
|
})
|
||||||
|
return reply.code(status).send(envelope)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Telemt unreachable'
|
||||||
|
return reply.code(502).send({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'telemt_unreachable', message },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: {
|
||||||
|
code: 'use_server_proxy',
|
||||||
|
message: 'В режиме fleet используйте /api/servers/:id/telemt/*',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/telemt/proxy', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
|
const parsed = telemtProxyRequestSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: { code: 'bad_request', message: 'Неверный запрос' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.config.panelMode !== 'standalone') {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: { code: 'fleet_mode', message: 'Proxy только для standalone' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { status, envelope } = await app.telemt.request(parsed.data)
|
||||||
|
return reply.code(status).send(envelope)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Telemt unreachable'
|
||||||
|
return reply.code(502).send({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'telemt_unreachable', message },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fleetRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/api/servers', { preHandler: requireAuth }, async () => {
|
||||||
|
if (app.config.panelMode === 'standalone') {
|
||||||
|
const reachable = await app.telemt.health()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'local',
|
||||||
|
name: 'Local Telemt',
|
||||||
|
status: reachable ? 'online' : 'offline',
|
||||||
|
mode: 'standalone',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return app.db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.orderBy(asc(agents.name))
|
||||||
|
.all()
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
status: r.status,
|
||||||
|
lastSeenAt: r.lastSeenAt,
|
||||||
|
hostname: r.hostname,
|
||||||
|
agentVersion: r.agentVersion,
|
||||||
|
mode: 'fleet',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.all('/api/servers/:id/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const suffix = (request.params as { id: string; '*': string })['*']
|
||||||
|
const path = `/v1/${suffix}`
|
||||||
|
const method = request.method.toUpperCase()
|
||||||
|
|
||||||
|
if (app.config.panelMode === 'standalone' || id === 'local') {
|
||||||
|
const ifMatch = request.headers['if-match']
|
||||||
|
try {
|
||||||
|
const { status, envelope } = await app.telemt.request({
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
|
||||||
|
ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined,
|
||||||
|
})
|
||||||
|
return reply.code(status).send(envelope)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Telemt unreachable'
|
||||||
|
return reply.code(502).send({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'telemt_unreachable', message },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = app.db.select().from(agents).where(eq(agents.id, id)).get()
|
||||||
|
if (!agent) {
|
||||||
|
return reply.code(404).send({ error: { code: 'not_found', message: 'Сервер не найден' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const jobId = randomUUID()
|
||||||
|
app.db
|
||||||
|
.insert(jobs)
|
||||||
|
.values({
|
||||||
|
id: jobId,
|
||||||
|
agentId: id,
|
||||||
|
type: 'telemt.proxy',
|
||||||
|
payloadJson: JSON.stringify({
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
|
||||||
|
}),
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const deadline = Date.now() + 15_000
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 200))
|
||||||
|
const job = app.db.select().from(jobs).where(eq(jobs.id, jobId)).get()
|
||||||
|
if (!job) break
|
||||||
|
if (job.status === 'done' && job.resultJson) {
|
||||||
|
try {
|
||||||
|
return reply.send(JSON.parse(job.resultJson))
|
||||||
|
} catch {
|
||||||
|
return reply.send({ ok: true, data: job.resultJson })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (job.status === 'error') {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: { code: 'agent_error', message: job.error ?? 'Ошибка агента' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(504).send({
|
||||||
|
error: { code: 'agent_timeout', message: 'Агент не ответил вовремя', jobId },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/enrollment-tokens', { preHandler: requireAuth }, async (_request, reply) => {
|
||||||
|
if (app.config.panelMode !== 'fleet') {
|
||||||
|
return reply.code(400).send({ error: { code: 'standalone', message: 'Только в режиме fleet' } })
|
||||||
|
}
|
||||||
|
return app.db
|
||||||
|
.select()
|
||||||
|
.from(enrollmentTokens)
|
||||||
|
.all()
|
||||||
|
.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
label: t.label,
|
||||||
|
expiresAt: t.expiresAt,
|
||||||
|
usedAt: t.usedAt,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/enrollment-tokens', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
|
if (app.config.panelMode !== 'fleet') {
|
||||||
|
return reply.code(400).send({ error: { code: 'standalone', message: 'Только в режиме fleet' } })
|
||||||
|
}
|
||||||
|
const body = (request.body ?? {}) as { label?: string; ttlHours?: number }
|
||||||
|
const token = randomBytes(24).toString('hex')
|
||||||
|
const now = new Date()
|
||||||
|
const expires = new Date(now.getTime() + (body.ttlHours ?? 24) * 3600_000)
|
||||||
|
const id = randomUUID()
|
||||||
|
app.db
|
||||||
|
.insert(enrollmentTokens)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
tokenHash: sha256(token),
|
||||||
|
label: body.label ?? 'default',
|
||||||
|
expiresAt: expires.toISOString(),
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const op = getOperator(request)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
token,
|
||||||
|
expiresAt: expires.toISOString(),
|
||||||
|
installCommand: `curl -fsSL ${app.config.panelPublicUrl}/install-agent.sh | sudo bash -s -- --panel-url ${app.config.panelPublicUrl} --token ${token}`,
|
||||||
|
createdBy: op.username,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/clients', { preHandler: requireAuth }, async () => {
|
||||||
|
return app.db.select().from(managedClients).all()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/clients', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
|
const body = (request.body ?? {}) as { username?: string; secret?: string }
|
||||||
|
if (!body.username) {
|
||||||
|
return reply.code(400).send({ error: { code: 'bad_request', message: 'username обязателен' } })
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id = randomUUID()
|
||||||
|
app.db
|
||||||
|
.insert(managedClients)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
username: body.username,
|
||||||
|
secret: body.secret ?? null,
|
||||||
|
metaJson: '{}',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
if (app.config.panelMode === 'standalone') {
|
||||||
|
const { status, envelope } = await app.telemt.request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/users',
|
||||||
|
body: { username: body.username, ...(body.secret ? { secret: body.secret } : {}) },
|
||||||
|
})
|
||||||
|
return reply.code(status).send({ local: { id, username: body.username }, telemt: envelope })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, username: body.username }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agentProtocolRoutes(app: FastifyInstance) {
|
||||||
|
app.post('/api/agent/enroll', async (request, reply) => {
|
||||||
|
if (app.config.panelMode !== 'fleet') {
|
||||||
|
return reply.code(400).send({ error: { code: 'standalone', message: 'Enrollment только в fleet' } })
|
||||||
|
}
|
||||||
|
const body = (request.body ?? {}) as {
|
||||||
|
token?: string
|
||||||
|
name?: string
|
||||||
|
hostname?: string
|
||||||
|
agentVersion?: string
|
||||||
|
}
|
||||||
|
if (!body.token) {
|
||||||
|
return reply.code(400).send({ error: { code: 'bad_request', message: 'token обязателен' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = sha256(body.token)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const row = app.db
|
||||||
|
.select()
|
||||||
|
.from(enrollmentTokens)
|
||||||
|
.where(and(eq(enrollmentTokens.tokenHash, tokenHash), isNull(enrollmentTokens.usedAt)))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!row || row.expiresAt < now) {
|
||||||
|
return reply.code(401).send({ error: { code: 'invalid_token', message: 'Токен недействителен' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentId = randomUUID()
|
||||||
|
const agentToken = randomBytes(32).toString('hex')
|
||||||
|
app.db
|
||||||
|
.insert(agents)
|
||||||
|
.values({
|
||||||
|
id: agentId,
|
||||||
|
name: body.name ?? body.hostname ?? `agent-${agentId.slice(0, 8)}`,
|
||||||
|
status: 'online',
|
||||||
|
tokenHash: sha256(agentToken),
|
||||||
|
lastSeenAt: now,
|
||||||
|
agentVersion: body.agentVersion ?? null,
|
||||||
|
hostname: body.hostname ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
app.db
|
||||||
|
.update(enrollmentTokens)
|
||||||
|
.set({ usedAt: now })
|
||||||
|
.where(eq(enrollmentTokens.id, row.id))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
return { agentId, agentToken, panelUrl: app.config.panelPublicUrl }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/agent/jobs', async (request, reply) => {
|
||||||
|
const auth = request.headers.authorization ?? ''
|
||||||
|
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||||||
|
if (!token) return reply.code(401).send({ error: { code: 'unauthorized' } })
|
||||||
|
|
||||||
|
const agent = app.db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.tokenHash, sha256(token)))
|
||||||
|
.get()
|
||||||
|
if (!agent) return reply.code(401).send({ error: { code: 'unauthorized' } })
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
app.db
|
||||||
|
.update(agents)
|
||||||
|
.set({ lastSeenAt: now, status: 'online', updatedAt: now })
|
||||||
|
.where(eq(agents.id, agent.id))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const pending = app.db
|
||||||
|
.select()
|
||||||
|
.from(jobs)
|
||||||
|
.where(and(eq(jobs.agentId, agent.id), eq(jobs.status, 'pending')))
|
||||||
|
.all()
|
||||||
|
|
||||||
|
for (const j of pending) {
|
||||||
|
app.db
|
||||||
|
.update(jobs)
|
||||||
|
.set({ status: 'running', updatedAt: now })
|
||||||
|
.where(eq(jobs.id, j.id))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending.map((j) => ({
|
||||||
|
id: j.id,
|
||||||
|
type: j.type,
|
||||||
|
payload: JSON.parse(j.payloadJson || '{}'),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/agent/jobs/:id/result', async (request, reply) => {
|
||||||
|
const auth = request.headers.authorization ?? ''
|
||||||
|
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||||||
|
if (!token) return reply.code(401).send({ error: { code: 'unauthorized' } })
|
||||||
|
|
||||||
|
const agent = app.db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.tokenHash, sha256(token)))
|
||||||
|
.get()
|
||||||
|
if (!agent) return reply.code(401).send({ error: { code: 'unauthorized' } })
|
||||||
|
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const job = app.db.select().from(jobs).where(eq(jobs.id, id)).get()
|
||||||
|
if (!job || job.agentId !== agent.id) {
|
||||||
|
return reply.code(404).send({ error: { code: 'not_found' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (request.body ?? {}) as { ok?: boolean; result?: unknown; error?: string }
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
app.db
|
||||||
|
.update(jobs)
|
||||||
|
.set({
|
||||||
|
status: body.ok === false ? 'error' : 'done',
|
||||||
|
resultJson: body.result !== undefined ? JSON.stringify(body.result) : null,
|
||||||
|
error: body.error ?? null,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(jobs.id, id))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { readFileSync, existsSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { hash } from '@node-rs/argon2'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { createDb, migrateSchema, operators } from '@telemt/db'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { loadConfig } from '../config.js'
|
||||||
|
|
||||||
|
for (const path of [resolve(import.meta.dirname, '../../../../.env'), '.env']) {
|
||||||
|
if (!existsSync(path)) continue
|
||||||
|
const content = readFileSync(path, 'utf-8')
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
const eqIdx = trimmed.indexOf('=')
|
||||||
|
if (eqIdx === -1) continue
|
||||||
|
const key = trimmed.slice(0, eqIdx).trim()
|
||||||
|
let value = trimmed.slice(eqIdx + 1).trim()
|
||||||
|
if (!(key in process.env)) process.env[key] = value
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
function flag(name: string): string | undefined {
|
||||||
|
const i = args.indexOf(`--${name}`)
|
||||||
|
return i >= 0 ? args[i + 1] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = flag('username') ?? process.env.BOOTSTRAP_USERNAME ?? 'admin'
|
||||||
|
const password = flag('password') ?? process.env.BOOTSTRAP_PASSWORD
|
||||||
|
if (!password) {
|
||||||
|
console.error('Usage: pnpm --filter api bootstrap-admin -- --username admin --password <pass>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig()
|
||||||
|
const { db, sqlite } = createDb(config.databaseUrl)
|
||||||
|
migrateSchema(sqlite)
|
||||||
|
|
||||||
|
const existing = db.select().from(operators).where(eq(operators.username, username)).get()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const passwordHash = await hash(password)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.update(operators)
|
||||||
|
.set({ passwordHash, updatedAt: now, disabled: false })
|
||||||
|
.where(eq(operators.id, existing.id))
|
||||||
|
.run()
|
||||||
|
console.log(`updated operator: ${username}`)
|
||||||
|
} else {
|
||||||
|
db.insert(operators)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
role: 'admin',
|
||||||
|
disabled: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
console.log(`created operator: ${username}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { readFileSync, existsSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { buildApp } from './app.js'
|
||||||
|
import { loadConfig } from './config.js'
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
resolve(import.meta.dirname, '../../../.env'),
|
||||||
|
resolve(import.meta.dirname, '../../../.env.local'),
|
||||||
|
'.env',
|
||||||
|
'../.env',
|
||||||
|
]) {
|
||||||
|
if (!existsSync(path)) continue
|
||||||
|
const content = readFileSync(path, 'utf-8')
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
const eq = trimmed.indexOf('=')
|
||||||
|
if (eq === -1) continue
|
||||||
|
const key = trimmed.slice(0, eq).trim()
|
||||||
|
let value = trimmed.slice(eq + 1).trim()
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
if (!(key in process.env)) process.env[key] = value
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig()
|
||||||
|
const app = await buildApp({ config })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
|
||||||
|
app.log.info(
|
||||||
|
`telemt-panel listening on ${config.serverPort} (mode=${config.panelMode})`,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export interface TelemtEnvelope<T = unknown> {
|
||||||
|
ok: boolean
|
||||||
|
data?: T
|
||||||
|
revision?: string
|
||||||
|
error?: { code: string; message: string }
|
||||||
|
request_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelemtClient {
|
||||||
|
constructor(
|
||||||
|
private readonly baseUrl: string,
|
||||||
|
private readonly authHeader: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async request<T = unknown>(opts: {
|
||||||
|
method?: string
|
||||||
|
path: string
|
||||||
|
body?: unknown
|
||||||
|
ifMatch?: string
|
||||||
|
}): Promise<{ status: number; envelope: TelemtEnvelope<T>; raw: string }> {
|
||||||
|
const path = opts.path.startsWith('/') ? opts.path : `/${opts.path}`
|
||||||
|
const url = `${this.baseUrl.replace(/\/$/, '')}${path}`
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
}
|
||||||
|
if (this.authHeader) headers.Authorization = this.authHeader
|
||||||
|
if (opts.body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const raw = await res.text()
|
||||||
|
let envelope: TelemtEnvelope<T>
|
||||||
|
try {
|
||||||
|
envelope = JSON.parse(raw) as TelemtEnvelope<T>
|
||||||
|
} catch {
|
||||||
|
envelope = {
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'bad_response', message: raw.slice(0, 200) || res.statusText },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: res.status, envelope, raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { status, envelope } = await this.request({ path: '/v1/health' })
|
||||||
|
return status === 200 && envelope.ok === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["node"],
|
||||||
|
"lib": ["ES2022"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Frontend (Vite)
|
||||||
|
# VITE_API_URL=
|
||||||
|
|
||||||
|
# ReUI PRO license (blocks / premium registry). Free components work without it.
|
||||||
|
# https://reui.io/docs/license-setup
|
||||||
|
REUI_LICENSE_KEY=
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "../../packages/ui/src/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"registries": {
|
||||||
|
"@reui": {
|
||||||
|
"url": "https://reui.io/r/{style}/{name}.json",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"hooks": "@/hooks",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"utils": "@telemt/ui/lib/utils",
|
||||||
|
"ui": "@telemt/ui/components"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Telemt Panel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "@telemt/web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.0.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
|
"@tanstack/react-router": "^1.130.2",
|
||||||
|
"@tanstack/react-router-devtools": "^1.130.2",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
|
"@telemt/shared": "workspace:*",
|
||||||
|
"@telemt/ui": "workspace:*",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.60.0",
|
||||||
|
"recharts": "3.8.0",
|
||||||
|
"sonner": "^1.7.0",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
|
"@tanstack/router-plugin": "^1.130.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"happy-dom": "^18.0.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"tw-animate-css": "^1.0.0",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
import { cn } from "@telemt/ui/lib/utils"
|
||||||
|
import { Item, ItemMedia } from "@telemt/ui/components/item"
|
||||||
|
|
||||||
|
export function AuthLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"p-0",
|
||||||
|
"text-primary flex size-8 shrink-0 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
<svg
|
||||||
|
width="50"
|
||||||
|
height="50"
|
||||||
|
viewBox="25.668 25.1352 49.6644 50"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="size-4"
|
||||||
|
>
|
||||||
|
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { Button } from "@telemt/ui/components/button"
|
||||||
|
import { Field, FieldGroup, FieldLabel } from "@telemt/ui/components/field"
|
||||||
|
import { Input } from "@telemt/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@telemt/ui/components/input-group"
|
||||||
|
import { Separator } from "@telemt/ui/components/separator"
|
||||||
|
import { AuthLogo } from "./auth-logo"
|
||||||
|
import { AUTH4_FOOTER_LINKS, AUTH4_PROVIDERS } from "./data"
|
||||||
|
import { EyeOffIcon, EyeIcon } from "lucide-react"
|
||||||
|
|
||||||
|
type FormSubmitHandler = NonNullable<React.ComponentProps<"form">["onSubmit"]>
|
||||||
|
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
|
||||||
|
|
||||||
|
function FooterMenu() {
|
||||||
|
return (
|
||||||
|
<footer className="relative z-10 bg-transparent">
|
||||||
|
<div className="mx-auto flex max-w-4xl flex-col items-center justify-center gap-4 px-6 py-4 text-center sm:flex-row sm:flex-wrap sm:gap-x-9 sm:gap-y-2">
|
||||||
|
<nav
|
||||||
|
aria-label="Footer"
|
||||||
|
className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2"
|
||||||
|
>
|
||||||
|
{AUTH4_FOOTER_LINKS.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.id}
|
||||||
|
href={link.href}
|
||||||
|
className="text-muted-foreground hover:text-primary cursor-pointer text-sm underline-offset-4 transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Auth() {
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false)
|
||||||
|
|
||||||
|
function handleSubmit(event: FormSubmitEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-svh w-full min-w-full flex-col">
|
||||||
|
<div className="flex w-full min-w-full flex-1 items-center justify-center px-4 py-8 sm:px-6 sm:py-10 lg:px-10">
|
||||||
|
<div className="mx-auto flex w-full max-w-[28rem] flex-col gap-4">
|
||||||
|
<Frame spacing="lg" className="w-full">
|
||||||
|
<FramePanel className="space-y-8 px-9 py-10 sm:space-y-9 sm:px-11 sm:py-11">
|
||||||
|
<div className="flex flex-col items-center gap-5 pt-3 text-center">
|
||||||
|
<AuthLogo className="size-9" />
|
||||||
|
|
||||||
|
<div className="flex max-w-xs flex-col gap-1.5">
|
||||||
|
<FrameTitle className="text-2xl tracking-tight sm:text-[1.75rem]">
|
||||||
|
Sign in
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription className="text-sm text-pretty">
|
||||||
|
Continue to your workspace.
|
||||||
|
</FrameDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field className="gap-2">
|
||||||
|
<FieldLabel htmlFor="auth-4-email">
|
||||||
|
Email or username
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="auth-4-email"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
placeholder="Email or username"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field className="gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<FieldLabel htmlFor="auth-4-password">
|
||||||
|
Password
|
||||||
|
</FieldLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
className="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupInput
|
||||||
|
id="auth-4-password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupButton
|
||||||
|
type="button"
|
||||||
|
size="icon-xs"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={
|
||||||
|
showPassword ? "Hide password" : "Show password"
|
||||||
|
}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
onClick={() => setShowPassword((value) => !value)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOffIcon aria-hidden="true" className="size-4" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon aria-hidden="true" className="size-4" />
|
||||||
|
)}
|
||||||
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{AUTH4_PROVIDERS.map((provider) => (
|
||||||
|
<Button
|
||||||
|
key={provider.id}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-center"
|
||||||
|
>
|
||||||
|
{provider.logo}
|
||||||
|
{provider.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
|
Need an account?{" "}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
className="text-primary hover:text-primary h-auto cursor-pointer p-0 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<FooterMenu />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import { Apple } from "@/components/ui/svgs/apple"
|
||||||
|
import { AppleDark } from "@/components/ui/svgs/appleDark"
|
||||||
|
import { GithubDark } from "@/components/ui/svgs/githubDark"
|
||||||
|
import { GithubLight } from "@/components/ui/svgs/githubLight"
|
||||||
|
import { Google } from "@/components/ui/svgs/google"
|
||||||
|
|
||||||
|
export type AuthProvider = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
logo: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FooterLink = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true" className="dark:hidden">
|
||||||
|
{light}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className="hidden dark:block">
|
||||||
|
{dark}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH4_PROVIDERS: AuthProvider[] = [
|
||||||
|
{
|
||||||
|
id: "google",
|
||||||
|
label: "Google",
|
||||||
|
logo: <Google aria-hidden="true" data-icon="inline-start" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "apple",
|
||||||
|
label: "Apple",
|
||||||
|
logo: (
|
||||||
|
<ThemeLogo
|
||||||
|
light={<Apple aria-hidden="true" data-icon="inline-start" />}
|
||||||
|
dark={<AppleDark aria-hidden="true" data-icon="inline-start" />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github",
|
||||||
|
label: "GitHub",
|
||||||
|
logo: (
|
||||||
|
<ThemeLogo
|
||||||
|
light={<GithubLight aria-hidden="true" data-icon="inline-start" />}
|
||||||
|
dark={<GithubDark aria-hidden="true" data-icon="inline-start" />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AUTH4_FOOTER_LINKS: FooterLink[] = [
|
||||||
|
{
|
||||||
|
id: "privacy",
|
||||||
|
label: "Privacy",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terms",
|
||||||
|
label: "Terms",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
label: "Status",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useId, type ComponentProps } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@telemt/ui/lib/utils"
|
||||||
|
|
||||||
|
export interface NoiseTextureProps extends ComponentProps<"svg"> {
|
||||||
|
className?: string
|
||||||
|
frequency?: number
|
||||||
|
octaves?: number
|
||||||
|
slope?: number
|
||||||
|
noiseOpacity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoiseTexture = ({
|
||||||
|
className,
|
||||||
|
frequency = 0.4,
|
||||||
|
octaves = 6,
|
||||||
|
slope = 0.15,
|
||||||
|
noiseOpacity = 0.6,
|
||||||
|
...props
|
||||||
|
}: NoiseTextureProps) => {
|
||||||
|
const filterId = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-0 z-0 h-full w-full opacity-50 select-none dark:opacity-[0.75]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<filter id={filterId}>
|
||||||
|
<feTurbulence
|
||||||
|
type="fractalNoise"
|
||||||
|
baseFrequency={frequency}
|
||||||
|
numOctaves={octaves}
|
||||||
|
stitchTiles="stitch"
|
||||||
|
/>
|
||||||
|
<feColorMatrix type="saturate" values="0" />
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncR type="linear" slope={slope} />
|
||||||
|
<feFuncG type="linear" slope={slope} />
|
||||||
|
<feFuncB type="linear" slope={slope} />
|
||||||
|
</feComponentTransfer>
|
||||||
|
</filter>
|
||||||
|
<rect
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
filter={`url(#${filterId})`}
|
||||||
|
opacity={noiseOpacity}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { useId } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@telemt/ui/lib/utils"
|
||||||
|
|
||||||
|
interface StripedPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||||
|
direction?: "left" | "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StripedPattern({
|
||||||
|
direction = "left",
|
||||||
|
className,
|
||||||
|
width = 10,
|
||||||
|
height = 10,
|
||||||
|
...props
|
||||||
|
}: StripedPatternProps) {
|
||||||
|
const id = useId()
|
||||||
|
const w = Number(width)
|
||||||
|
const h = Number(height)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-0 z-10 h-full w-full stroke-[0.5]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern id={id} width={w} height={h} patternUnits="userSpaceOnUse">
|
||||||
|
{direction === "left" ? (
|
||||||
|
<>
|
||||||
|
<line x1="0" y1={h} x2={w} y2="0" stroke="currentColor" />
|
||||||
|
<line x1={-w} y1={h} x2="0" y2="0" stroke="currentColor" />
|
||||||
|
<line x1={w} y1={h} x2={w * 2} y2="0" stroke="currentColor" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="0" y1="0" x2={w} y2={h} stroke="currentColor" />
|
||||||
|
<line x1={-w} y1="0" x2="0" y2={h} stroke="currentColor" />
|
||||||
|
<line x1={w} y1="0" x2={w * 2} y2={h} stroke="currentColor" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill={`url(#${id})`} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Auth } from "./components/auth"
|
||||||
|
import { NoiseTexture } from "./components/noise-texture"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background relative min-h-svh w-full min-w-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<NoiseTexture
|
||||||
|
className="text-foreground/[0.015] dark:text-foreground/[0.03]"
|
||||||
|
frequency={0.5}
|
||||||
|
octaves={5}
|
||||||
|
slope={0.08}
|
||||||
|
noiseOpacity={0.28}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 min-h-svh min-w-full">
|
||||||
|
<Auth />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export type ToolbarOption<T extends string> = {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPORT_AUDIENCE_OPTIONS = [
|
||||||
|
{ value: "everyone", label: "Everyone" },
|
||||||
|
{ value: "ops-leads", label: "Ops leads" },
|
||||||
|
{ value: "finance-reviewers", label: "Finance reviewers" },
|
||||||
|
{ value: "client-owners", label: "Client owners" },
|
||||||
|
] as const satisfies readonly ToolbarOption<string>[]
|
||||||
|
|
||||||
|
export const EXPORT_SCOPE_OPTIONS = [
|
||||||
|
{ value: "all-workspaces", label: "All workspaces" },
|
||||||
|
{ value: "harbor-field", label: "Harbor field" },
|
||||||
|
{ value: "market-lab", label: "Market lab" },
|
||||||
|
{ value: "support-desk", label: "Support desk" },
|
||||||
|
] as const satisfies readonly ToolbarOption<string>[]
|
||||||
|
|
||||||
|
export const EXPORT_RANGE_OPTIONS = [
|
||||||
|
{ value: "last-30-days", label: "Last 30 days" },
|
||||||
|
{ value: "this-quarter", label: "This quarter" },
|
||||||
|
{ value: "previous-cycle", label: "Previous cycle" },
|
||||||
|
{ value: "custom-window", label: "Custom window" },
|
||||||
|
] as const satisfies readonly ToolbarOption<string>[]
|
||||||
|
|
||||||
|
export const EXPORT_FORMAT_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "csv",
|
||||||
|
label: "CSV bundle",
|
||||||
|
description: "Spreadsheet-ready activity rows",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "pdf",
|
||||||
|
label: "PDF brief",
|
||||||
|
description: "A concise review packet for stakeholders",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "schedule",
|
||||||
|
label: "Schedule delivery",
|
||||||
|
description: "Send this export every Friday morning",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"]
|
||||||
|
export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"]
|
||||||
|
export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"]
|
||||||
|
export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"]
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { IconStack } from "@/components/reui/icon-stack"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@telemt/ui/components/button"
|
||||||
|
import {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
} from "@telemt/ui/components/button-group"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@telemt/ui/components/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@telemt/ui/components/empty"
|
||||||
|
import { Separator } from "@telemt/ui/components/separator"
|
||||||
|
import {
|
||||||
|
EXPORT_AUDIENCE_OPTIONS,
|
||||||
|
EXPORT_FORMAT_OPTIONS,
|
||||||
|
EXPORT_RANGE_OPTIONS,
|
||||||
|
EXPORT_SCOPE_OPTIONS,
|
||||||
|
type ExportAudience,
|
||||||
|
type ExportFormat,
|
||||||
|
type ExportRange,
|
||||||
|
type ExportScope,
|
||||||
|
type ToolbarOption,
|
||||||
|
} from "./data"
|
||||||
|
import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react"
|
||||||
|
|
||||||
|
type ToolbarFilterProps<T extends string> = {
|
||||||
|
label: string
|
||||||
|
value: T
|
||||||
|
options: readonly ToolbarOption<T>[]
|
||||||
|
onValueChange: (value: T) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionLabel<T extends string>(
|
||||||
|
options: readonly ToolbarOption<T>[],
|
||||||
|
value: T
|
||||||
|
) {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarFilter<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onValueChange,
|
||||||
|
}: ToolbarFilterProps<T>) {
|
||||||
|
const selectedLabel = getOptionLabel(options, value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`${label}: ${selectedLabel}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedLabel}</span>
|
||||||
|
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="start" className="min-w-44">
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={value}
|
||||||
|
onValueChange={(nextValue) => {
|
||||||
|
if (nextValue !== null) {
|
||||||
|
onValueChange(nextValue as T)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
closeOnClick
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState() {
|
||||||
|
const [audience, setAudience] = useState<ExportAudience>(
|
||||||
|
EXPORT_AUDIENCE_OPTIONS[0].value
|
||||||
|
)
|
||||||
|
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
|
||||||
|
const [range, setRange] = useState<ExportRange>(EXPORT_RANGE_OPTIONS[0].value)
|
||||||
|
|
||||||
|
const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience)
|
||||||
|
const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope)
|
||||||
|
const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range)
|
||||||
|
|
||||||
|
const showExportToast = (format: ExportFormat = "csv") => {
|
||||||
|
const formatOption =
|
||||||
|
EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ??
|
||||||
|
EXPORT_FORMAT_OPTIONS[0]
|
||||||
|
|
||||||
|
toast.message(`${formatOption.label} is ready to wire`, {
|
||||||
|
description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex min-h-[430px] w-full max-w-4xl flex-col"
|
||||||
|
aria-labelledby="export-ledger-heading"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="flex min-w-0 flex-col gap-5">
|
||||||
|
{/* Title and Description */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{/* Title */}
|
||||||
|
<h2
|
||||||
|
id="export-ledger-heading"
|
||||||
|
className="text-2xl font-semibold tracking-tight"
|
||||||
|
>
|
||||||
|
Activity Exports
|
||||||
|
</h2>
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Download scoped activity packets for billing review, staffing
|
||||||
|
audits, and client handoffs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ToolbarFilter
|
||||||
|
label="Audience"
|
||||||
|
value={audience}
|
||||||
|
options={EXPORT_AUDIENCE_OPTIONS}
|
||||||
|
onValueChange={setAudience}
|
||||||
|
/>
|
||||||
|
<ToolbarFilter
|
||||||
|
label="Workspace"
|
||||||
|
value={scope}
|
||||||
|
options={EXPORT_SCOPE_OPTIONS}
|
||||||
|
onValueChange={setScope}
|
||||||
|
/>
|
||||||
|
<ToolbarFilter
|
||||||
|
label="Date range"
|
||||||
|
value={range}
|
||||||
|
options={EXPORT_RANGE_OPTIONS}
|
||||||
|
onValueChange={setRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Action */}
|
||||||
|
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
|
onClick={() => showExportToast()}
|
||||||
|
>
|
||||||
|
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
|
||||||
|
<span>Execute</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ButtonGroupSeparator className="bg-primary/72" />
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
className="border-primary-foreground/20 rounded-l-none border-l"
|
||||||
|
aria-label="Open download options"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon aria-hidden="true" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={() => showExportToast("csv")}>
|
||||||
|
<FileDownIcon aria-hidden="true" />
|
||||||
|
CSV bundle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
|
||||||
|
<BookOpenIcon aria-hidden="true" />
|
||||||
|
PDF brief
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
|
||||||
|
<CalendarClockIcon aria-hidden="true" />
|
||||||
|
Schedule delivery
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
|
||||||
|
<Empty className="max-w-md flex-none bg-transparent p-0">
|
||||||
|
<EmptyHeader className="gap-5 text-center">
|
||||||
|
<EmptyMedia className="mb-0">
|
||||||
|
<IconStack aria-hidden="true">
|
||||||
|
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
|
||||||
|
</IconStack>
|
||||||
|
</EmptyMedia>
|
||||||
|
|
||||||
|
{/* Empty State Content */}
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||||
|
No exportable activity yet
|
||||||
|
</EmptyTitle>
|
||||||
|
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||||
|
Capture approved activity and this view will assemble your next
|
||||||
|
review packet.
|
||||||
|
</EmptyDescription>
|
||||||
|
</div>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { EmptyState } from "./components/empty-state"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
|
||||||
|
aria-labelledby="page-heading"
|
||||||
|
>
|
||||||
|
<h1 id="page-heading" className="sr-only">
|
||||||
|
Activity exports empty state
|
||||||
|
</h1>
|
||||||
|
<EmptyState />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@telemt/ui/components/tabs'
|
||||||
|
import { cn } from '@telemt/ui/lib/utils'
|
||||||
|
|
||||||
|
export interface CountedLineTab {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountedLineTabsProps {
|
||||||
|
tabs: CountedLineTab[]
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
className?: string
|
||||||
|
listClassName?: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
|
||||||
|
export function CountedLineTabs({
|
||||||
|
tabs,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
className,
|
||||||
|
listClassName,
|
||||||
|
children,
|
||||||
|
}: CountedLineTabsProps) {
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onValueChange={onValueChange} className={className}>
|
||||||
|
<TabsList variant="line" className={cn('gap-5', listClassName)}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.id}
|
||||||
|
value={tab.id}
|
||||||
|
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.count !== undefined ? (
|
||||||
|
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{children}
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface DataGridColumn<T> {
|
||||||
|
key: string
|
||||||
|
header: ReactNode
|
||||||
|
cell: (row: T, index: number) => ReactNode
|
||||||
|
icon?: LucideIcon
|
||||||
|
sortable?: boolean
|
||||||
|
sortValue?: (row: T) => string | number
|
||||||
|
/** TanStack sortingFn; для числовых sortValue — `'basic'`. */
|
||||||
|
sortingFn?: 'auto' | 'alphanumeric' | 'basic' | 'text' | 'datetime'
|
||||||
|
headerTitle?: string
|
||||||
|
className?: string
|
||||||
|
headerClassName?: string
|
||||||
|
enableHiding?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте DataGridColumn */
|
||||||
|
export type DataTableColumn<T> = DataGridColumn<T>
|
||||||
|
|
||||||
|
/** Унифицированные классы колонок для FrameDataGrid. */
|
||||||
|
export const COL = {
|
||||||
|
num: 'w-28 text-right tabular-nums',
|
||||||
|
date: 'w-32 text-right tabular-nums text-muted-foreground',
|
||||||
|
actions: 'w-24 text-right',
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* ReUI Empty + IconStack — adapted from empty-state-12.
|
||||||
|
* Preview: https://reui.io/preview/base/empty-state-12
|
||||||
|
* Docs: https://reui.io/blocks
|
||||||
|
*/
|
||||||
|
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||||
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '@telemt/ui/components/empty'
|
||||||
|
import { cn } from '@telemt/ui/lib/utils'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
/** Lucide icon component or pre-rendered node (legacy). */
|
||||||
|
icon?: LucideIcon | ReactNode
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: ReactNode
|
||||||
|
className?: string
|
||||||
|
/** Use IconStack media (empty-state-12). Default true. */
|
||||||
|
stackedIcon?: boolean
|
||||||
|
/**
|
||||||
|
* Center in available width/height (empty-state-12).
|
||||||
|
* Set false for tight panels/sheets where the parent already centers.
|
||||||
|
*/
|
||||||
|
centered?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
||||||
|
if (typeof icon === 'function') return true
|
||||||
|
// lucide-react icons are forwardRef objects ({ $$typeof, render, displayName })
|
||||||
|
if (icon != null && typeof icon === 'object' && '$$typeof' in icon) {
|
||||||
|
// React elements have `props` — those are pre-rendered nodes, not components
|
||||||
|
if ('props' in icon) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
stackedIcon = true,
|
||||||
|
centered = true,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||||
|
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<Empty
|
||||||
|
className={cn(
|
||||||
|
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||||
|
!centered && className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EmptyHeader className="gap-5 text-center">
|
||||||
|
<EmptyMedia className="mb-0">
|
||||||
|
{customIcon ? (
|
||||||
|
<div className="text-muted-foreground">{customIcon}</div>
|
||||||
|
) : stackedIcon ? (
|
||||||
|
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
|
||||||
|
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
||||||
|
</IconStack>
|
||||||
|
) : (
|
||||||
|
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</EmptyMedia>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||||
|
{title}
|
||||||
|
</EmptyTitle>
|
||||||
|
{description ? (
|
||||||
|
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||||
|
{description}
|
||||||
|
</EmptyDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</EmptyHeader>
|
||||||
|
{action ? (
|
||||||
|
<EmptyContent className="mt-1 items-center justify-center">
|
||||||
|
{action}
|
||||||
|
</EmptyContent>
|
||||||
|
) : null}
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!centered) return body
|
||||||
|
|
||||||
|
// empty-state-12: center in available height (parent must be flex column / stretch)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Server,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Activity,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from '@telemt/ui/components/sidebar'
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from '@telemt/ui/components/breadcrumb'
|
||||||
|
import { Separator } from '@telemt/ui/components/separator'
|
||||||
|
import { TooltipProvider } from '@telemt/ui/components/tooltip'
|
||||||
|
|
||||||
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: typeof LayoutDashboard
|
||||||
|
fleetOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavGroup {
|
||||||
|
label: string
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: 'Обзор',
|
||||||
|
items: [{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Telemt',
|
||||||
|
items: [
|
||||||
|
{ to: '/users', label: 'Пользователи', icon: Users },
|
||||||
|
{ to: '/runtime', label: 'Runtime', icon: Activity },
|
||||||
|
{ to: '/security', label: 'Безопасность', icon: Shield },
|
||||||
|
{ to: '/servers', label: 'Серверы', icon: Server, fleetOnly: true },
|
||||||
|
{ to: '/clients', label: 'Клиенты', icon: Users, fleetOnly: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Система',
|
||||||
|
items: [{ to: '/settings', label: 'Настройки', icon: Settings }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** @see https://reui.io/preview/base/app-shell-12 · docs: https://reui.io/docs/blocks */
|
||||||
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const config = useQuery({
|
||||||
|
queryKey: ['auth-config'],
|
||||||
|
queryFn: () => api<{ panelMode: string }>('/api/auth/config'),
|
||||||
|
})
|
||||||
|
const mode = config.data?.panelMode ?? 'standalone'
|
||||||
|
|
||||||
|
const groups = NAV_GROUPS.map((g) => ({
|
||||||
|
...g,
|
||||||
|
items: g.items.filter((i) => !i.fleetOnly || mode === 'fleet'),
|
||||||
|
})).filter((g) => g.items.length > 0)
|
||||||
|
|
||||||
|
const allItems = groups.flatMap((g) => g.items)
|
||||||
|
const activeItem =
|
||||||
|
allItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? allItems[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delay={0}>
|
||||||
|
<SidebarProvider style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md text-xs font-semibold">
|
||||||
|
TM
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col group-data-[collapsible=icon]:hidden">
|
||||||
|
<span className="truncate text-sm font-semibold">Telemt Panel</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{mode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<SidebarGroup key={group.label}>
|
||||||
|
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive =
|
||||||
|
pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.to}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
render={<Link to={item.to} />}
|
||||||
|
isActive={isActive}
|
||||||
|
tooltip={item.label}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
<SidebarInset>
|
||||||
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem className="hidden md:block">
|
||||||
|
<BreadcrumbLink render={<Link to="/dashboard" />}>Telemt</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>{activeItem?.label ?? '…'}</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import {
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
MoonIcon,
|
||||||
|
SunIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@telemt/ui/lib/utils'
|
||||||
|
import { Avatar, AvatarFallback } from '@telemt/ui/components/avatar'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@telemt/ui/components/dropdown-menu'
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@telemt/ui/components/sidebar'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { clearToken } from '@/lib/auth'
|
||||||
|
|
||||||
|
/** Sidebar footer account menu — ReUI NavUser. @see https://reui.io/preview/base/app-shell-12 */
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
{
|
||||||
|
value: 'light',
|
||||||
|
label: 'Светлая',
|
||||||
|
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark',
|
||||||
|
label: 'Тёмная',
|
||||||
|
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'system',
|
||||||
|
label: 'Системная',
|
||||||
|
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function ThemeSegmentedToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Тема"
|
||||||
|
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
{THEMES.map((item) => {
|
||||||
|
const isActive = currentTheme === item.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
aria-label={item.label}
|
||||||
|
onClick={() => setTheme(item.value)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex size-7 items-center justify-center rounded-full transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavUser() {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const me = useQuery({
|
||||||
|
queryKey: ['auth-me'],
|
||||||
|
queryFn: () =>
|
||||||
|
api<{ operator: { username: string; role: string } }>('/api/auth/me'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const username = me.data?.operator.username ?? 'admin'
|
||||||
|
const initials = username.slice(0, 2).toUpperCase()
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
clearToken()
|
||||||
|
void navigate({ to: '/login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-[popup-open]:bg-sidebar-accent data-[popup-open]:text-sidebar-accent-foreground"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar className="size-8 rounded-lg">
|
||||||
|
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-medium">{username}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{me.data?.operator.role ?? 'Local auth'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||||
|
side={isMobile ? 'bottom' : 'right'}
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
|
<Avatar className="size-8 rounded-lg">
|
||||||
|
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-medium">{username}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">Local JWT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Тема</span>
|
||||||
|
<ThemeSegmentedToggle />
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
<LogOutIcon />
|
||||||
|
Выйти
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page-level section header — etalon EvoBGP. */
|
||||||
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { cn } from '@telemt/ui/lib/utils'
|
||||||
|
|
||||||
|
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { cn } from '@telemt/ui/lib/utils'
|
||||||
|
|
||||||
|
export interface DetailMetricCard {
|
||||||
|
id: string
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailPanelProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||||
|
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailPanelHeaderProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
}: DetailPanelHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
||||||
|
return (
|
||||||
|
<div className="@container w-full">
|
||||||
|
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Frame key={card.id} spacing="sm">
|
||||||
|
<FrameHeader className="px-1! py-1!">
|
||||||
|
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||||
|
{card.icon}
|
||||||
|
<span className="text-foreground text-sm font-medium">
|
||||||
|
{card.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-2">
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
{card.footer}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
{title ? (
|
||||||
|
<header className="px-1">
|
||||||
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
|
{description ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailPanel = Object.assign(DetailPanelRoot, {
|
||||||
|
Header: DetailPanelHeader,
|
||||||
|
Metrics: DetailPanelMetrics,
|
||||||
|
Section: DetailPanelSection,
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Filter } from '@/components/reui/filters'
|
||||||
|
|
||||||
|
export function getActiveFilters(filters: Filter[]) {
|
||||||
|
return filters.filter((filter) => {
|
||||||
|
const { values } = filter
|
||||||
|
if (!values || values.length === 0) return false
|
||||||
|
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (values.every((value) => value === null || value === undefined)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFiltersToData<T>(
|
||||||
|
data: T[],
|
||||||
|
filters: Filter[],
|
||||||
|
getFieldValue: (item: T, field: string) => unknown,
|
||||||
|
): T[] {
|
||||||
|
const active = getActiveFilters(filters)
|
||||||
|
let result = [...data]
|
||||||
|
|
||||||
|
for (const filter of active) {
|
||||||
|
const { field, operator, values } = filter
|
||||||
|
result = result.filter((item) => {
|
||||||
|
const raw = getFieldValue(item, field)
|
||||||
|
const fieldValue = raw != null ? raw : ''
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'is':
|
||||||
|
return values.includes(fieldValue)
|
||||||
|
case 'is_not':
|
||||||
|
return !values.includes(fieldValue)
|
||||||
|
case 'is_any_of':
|
||||||
|
return values.some((value) => fieldValue === value)
|
||||||
|
case 'is_not_any_of':
|
||||||
|
return !values.some((value) => fieldValue === value)
|
||||||
|
case 'contains': {
|
||||||
|
const tokens = values
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (tokens.length === 0) return true
|
||||||
|
return tokens.some((token) =>
|
||||||
|
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'not_contains':
|
||||||
|
return !values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
|
||||||
|
)
|
||||||
|
case 'starts_with':
|
||||||
|
return values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
|
||||||
|
)
|
||||||
|
case 'ends_with':
|
||||||
|
return values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
|
||||||
|
)
|
||||||
|
case 'empty':
|
||||||
|
return fieldValue === '' || fieldValue == null
|
||||||
|
case 'not_empty':
|
||||||
|
return fieldValue !== '' && fieldValue != null
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSelectedCount(values: unknown[]) {
|
||||||
|
if (values.length === 0) return 'Выберите…'
|
||||||
|
if (values.length > 1) return `${values.length} выбрано`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSingleSelectedLabel(
|
||||||
|
values: unknown[],
|
||||||
|
options: { value: string; label: string }[],
|
||||||
|
) {
|
||||||
|
const state = renderSelectedCount(values)
|
||||||
|
if (state) return state
|
||||||
|
const option = options.find((item) => item.value === values[0])
|
||||||
|
return option?.label ?? String(values[0])
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user