- name
- design-system
- description
- Aisets UI design system — token usage, CVA component patterns, Tailwind co-location rules, and pre-delivery checklist. Use this skill whenever editing ANY file under ui/src/, including .tsx, .ts, and style files. Also use when creating new UI components, modifying existing ones, changing colors/spacing/typography, adding animations, or touching anything visual. Even simple one-line UI tweaks should consult this skill — the token and cascade rules catch subtle bugs.
- globs
- ["ui/src/**/*.tsx","ui/src/**/*.ts","ui/src/styles/**"]
# Aisets Design System
## Why this skill exists
This project uses a strict token-based design system with CVA (class-variance-authority) for
component variants and Tailwind for co-located styles. The system prevents visual inconsistencies,
dark/light theme breakage, and cascade conflicts that are hard to debug. Every rule below exists
because someone hit the problem it prevents.
---
## 1. How styling works
```
┌─ _tokens.scss ──── CSS custom properties (--g-canvas, --g-ink, --g-accent, etc.)
│ `:root` = light, `[data-theme="dark"]` = canonical dark
│
├─ tailwind.css ──── @theme block maps --g-* → Tailwind classes
│ e.g. --color-g-surface: var(--g-surface) → `bg-g-surface`
│ The `g-` prefix in class names means "uses a design token"
│
├─ _patterns.scss ── @keyframes, .sr-only, .bg-checker (shared utilities)
│
├─ components/ui/ ── CVA primitives (Button, Modal, Select, Badge, etc.)
│ Each exports a `*Variants` function + component
│
└─ components/ ───── Page components use Tailwind classes directly in JSX
```
**The only SCSS files are `_tokens.scss`, `_patterns.scss`, and `globals.scss`.
No component styles live in SCSS. Everything visual is co-located in `.tsx`.**
### Token naming convention
Tailwind classes use the `g-` prefix to reference design tokens:
- Colors: `bg-g-surface`, `text-g-ink`, `border-g-line`, `text-g-red`
- Radius: `rounded-g-sm` (4px), `rounded-g-md` (6px), `rounded-g-lg` (12px)
- Shadows: `shadow-g-sm`, `shadow-g-md`, `shadow-g-pop`, `shadow-g-focus`
- Fonts: `font-g` (body), `font-g-mono`, `font-g-display`
- Text: `text-g-chip` (10px), `text-g-caption` (11px), `text-g-ui` (12px), `text-g-body` (13px)
- Easing: `ease-g`, `ease-g-out`, `ease-g-spring`
- Button heights: `h-g-btn-sm` (26px), `h-g-btn-md` (32px), `h-g-btn-lg` (36px)
Full token values are in `_tokens.scss`. Full @theme mapping is in `tailwind.css`.
### Cascade gotcha (important)
SCSS files (`_tokens.scss`, `_patterns.scss`) are NOT inside a CSS `@layer`. Tailwind v4
utilities ARE in a layer. This means **SCSS properties always beat Tailwind utilities** in
the cascade.
Practical consequence: if an element has both a SCSS class and a Tailwind class for the same
property (e.g., `.content-scroll` sets `padding: 32px` and you add `p-4`), the SCSS wins
silently. The fix: **remove the SCSS class entirely** and replace with full Tailwind.
Also: `twMerge` in Tailwind v4 can't always resolve spacing-scale utilities (`w-80`) against
keyword utilities (`w-full`). Use arbitrary values (`w-[320px]`) when combining with CVA bases.
### twMerge font-size vs color conflict (critical)
`twMerge` treats ALL `text-*` classes as one group. Custom theme tokens like `text-g-ui`
(font-size) and `text-g-ink` (color) look identical to twMerge — it keeps only the last one,
**silently dropping font-size**. This is fixed in `cn.ts` via `extendTailwindMerge` registering
font-size tokens in a separate class group.
**Before changing font-size tokens:** run `twMerge('text-g-ui text-g-ink')` to verify the
font-size class survives. If it's dropped, fix `cn.ts` `classGroups.font-size` first.
**When adding new `text-*` theme tokens:** register them in `cn.ts` `extendTailwindMerge →
classGroups → font-size` or they will be silently eaten by color classes.
---
## 2. Component pattern
### New UI primitive → CVA
```tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/cn";
const widgetVariants = cva("base tailwind classes here", {
variants: {
variant: { primary: "...", secondary: "..." },
size: { sm: "...", md: "..." },
},
defaultVariants: { variant: "primary", size: "md" },
});
type WidgetProps = React.HTMLAttributes<HTMLElement> &
VariantProps<typeof widgetVariants>;
function Widget({ variant, size, className, ...props }: WidgetProps) {
return <div className={cn(widgetVariants({ variant, size }), className)} {...props} />;
}
export { Widget, widgetVariants };
```
### Page component → direct Tailwind
Page-level components don't need CVA — just use Tailwind classes in `className` with `cn()`
for conditionals. Use the existing UI primitives for buttons, inputs, modals, etc.
### Before creating a new component
Read the barrel export at `ui/src/components/ui/index.ts` to see what already exists. The
project has 21+ primitives including Button, Badge, Card, Modal, Select, Tabs, Tooltip,
DropdownMenu, Notice, Toast, EmptyState, and more. Check before building from scratch.
---
## 3. Page layout philosophy
Every view follows the Browse / Duplicates blueprint. Deviating from this structure makes the
product feel inconsistent.
### 3.1 Content-first — no decorative chrome
- **No page titles or hero sections.** Content (StatCards, toolbar, grid) starts at the top edge.
- **No standalone empty states.** If a page has a primary action area (e.g. dropzone), merge the
empty-state message into that area. Never stack two centered visual blocks vertically.
- **No description paragraphs.** The sidebar nav label is sufficient context. Add help via tooltip.
### 3.2 Three-layer structure
Every data view follows: **StatCards → Sticky Toolbar → Content Grid**.
```
┌──────────────────────────────────────────────┐
│ [StatCard] [StatCard] [StatCard] [StatCard] │ ← summary row
│ [StackedBar ···························] │ ← optional health bar
├──────────────────────────────────────────────┤
│ [Tabs] [Search···] [Sort ▾] [View ▾] [Act] │ ← sticky toolbar (z:4–5)
├──────────────────────────────────────────────┤
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │card│ │card│ │card│ │card│ │card│ │card│ │ ← content grid
│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │
└──────────────────────────────────────────────┘
```
- Omit layers that don't apply (e.g. PreCheck has no toolbar, Browse has no StatCards).
- Sidebar `FilterRail` is a separate column — never embed filters in the main content area.
### 3.3 StatCard neutrality
StatCard icon and label are always `text-g-ink-4` (neutral grey). The large number value provides
emphasis. Semantic color goes only on badge/chip elements in the content area, not on stat labels.
### 3.4 Information density
- 4px base spacing, compact density. Avoid large padding between functional elements.
- File metadata uses compact `<Badge>` chips, not full sentences.
- Details open in drawers/panels on click — not inline-expanded paragraphs.
---
## 4. Component rules
These aren't taste preferences — each prevents a specific class of bugs:
| Rule | Why |
|------|-----|
| **All values from `--g-*` tokens** | Raw hex breaks when themes switch. A hardcoded `#ffffff` is invisible on light canvas. |
| **Single CTA per screen** | Multiple `--g-cta` buttons create visual competition. Users don't know where to click. |
| **Color + icon + text for status** | ~8% of males are colorblind. Color alone is invisible to them. |
| **6px default radius (`--g-r-md`)** | Consistency across the product. Only overlays get 12px. |
| **Tooltips via Radix, not `title`** | Native tooltips can't be styled, have inconsistent delay, and no keyboard trigger. |
| **Lucide icons only** | Mixing icon sets (or using emoji) creates visual noise. |
| **`cn()` for all class merging** | It wraps `clsx` + `twMerge` — handles conditional classes and deduplicates conflicts. |
| **No decorative left-edge bars** | Never add purely decorative vertical colored bars on container edges. Exception: functional severity indicators (`border-l-[3px]` + severity color) are allowed on data rows when severity is a primary dimension (e.g. Optimize list items). Always pair with `border-l-transparent` fallback for consistent alignment and add extra `pl-5` to separate the bar from content. |
| **StatCard icon mandatory** | Every `<StatCard>` must have `icon={<LucideIcon size={14} />}`. Omitting icons on some cards in a grid breaks visual rhythm. |
| **i18n code-first** | Never render backend strings directly. Use `t(\`ns.${code}\`, { defaultValue: raw })` with the machine code field. |
| **AI contracts stay English-only** | Every AI-facing prompt, system/follow-up/repair prompt, tool schema, tool arg, label, description, impact, stream status code, and action metadata stays English/structured, even for non-English user messages. Define response formats that separate machine fields from display text, then localize only assistant prose and UI-visible status/reply strings through i18n/settings; hard-coded non-English intent, synonym, count/unit, or fallback phrases in UI/backend logic become hidden business rules. |
| **AI-readable asset payloads** | Canvas snapshots, debug JSON, and compact tool results must include file name, repo path, image format/dimensions/bytes, visual `url`/`thumbnailUrl`, AI tag summaries, OCR text when available, and stable IDs. IDs alone force extra lookups and make image-description tasks unreliable. |
| **Canvas terminal answer phase** | Tool-use flows must finish with a localized display reply when the user requested a status or description. Keep tool contracts English/structured, but render the final prose from compact tool results or i18n data instead of looping through more focus/layout actions. |
| **Intentional multilingual data is explicit** | Native-language text is allowed only when it is the data itself: language names, OCR/language aliases, Unicode language detection regexes, and deliberate semantic loading phrase arrays such as `SEMANTIC_PHASES`. Do not reuse those exceptions for AI prompts, tool metadata, status codes, or intent routing. |
| **i18n key hygiene** | Add English for every new key. Add non-English locale entries only for the display language(s) the change actually surfaces or the user asks for; do not bulk-fill all five locales speculatively. Remove unused translation keys instead of keeping stale duplicates that hide missing-key regressions. |
| **Locale parity on request** | When a feature is already translated across locales or the user explicitly asks to fill the other languages, compare every locale against `en` and validate `{{placeholder}}` parity before delivery. Missing keys silently fall back to English or produce partially translated UI. |
| **Collapsible via grid-rows** | Animated expand/collapse uses `grid-rows-[0fr]` → `grid-rows-[1fr]` with `overflow-hidden` inner div. Add `motion-reduce:transition-none`. |
| **Upload results accumulate** | Multi-batch upload flows append to existing results via ref + `setResults(prev => [...prev, ...incoming])`. Clear button resets all. |
| **Module-level state cache** | Views that unmount on route change use module-level `let _state` + `useEffect` sync to persist data across navigations. Cleared on page refresh. |
| **Drawer tabs stay mounted** | Drawer tab panels that contain forms, async output, or AI results must switch visibility with `hidden`/CSS instead of conditional-rendering only the active tab. Key the mounted panel group by `asset.id` or the drawer entity ID so state resets on item changes, not tab changes; otherwise AI messages and local state disappear when users inspect another tab. |
| **Virtual scroll: no fixed height** | `useVirtualizer` items must NOT set `height: vItem.size` on wrappers — it clips content when images load. Use only `transform: translateY(...)` + `measureElement` ref. |
| **Sticky headers in virtual lists** | `position: sticky` doesn't work on absolute-positioned virtual items. Render a sticky indicator in the toolbar div that reads the current section from visible virtual items. |
| **`content-scroll` on the real scroller** | Put `content-scroll` on the element with `scrollHeight > clientHeight`, not a non-scrolling wrapper. `ScrollToTop` picks the overflowing one. Early-return branches (loading/empty) must NOT have `content-scroll` — it poisons the `ScrollToTop` binding. |
| **Server-side filtering** | FilterRail filters → API query params (re-fetch), not client-side `useMemo` on loaded data. Lazy-loaded data is partial. |
| **API totals, not loaded counts** | StatCards, Tabs, FilterRail use `query.data.pages[0].total` / `.facets`. Never `items.length` during lazy loading. |
| **Lazy load via sentinel** | Use `useInfiniteScrollSentinel`, not `useEffect` auto-pagination. |
| **Facets match the view's unit** | DuplicatesView shows groups → facets must count groups (from duplicates API), not files (from items API). Use `project_name` not `project_id` for display. |
| **Query key normalizers** | Adding a filter param to `CatalogXxxParams` → also add it to `normalizeCatalogXxxParams` in `queries.ts`. Omitting it means the React Query cache key won't change, silently returning stale data. |
| **Cross-filter facets** | FilterRail facets use cross-filter: project facets computed with ext filter (project cleared), vice versa. Backend facet function accepts both params, clears the "self" dimension. |
| **Toolbar controls: 32px height** | All toolbar filter controls — TextInput, Tabs, Select — use `h-g-btn-md` (32px). TextInput uses `variant="search"`. Never use custom styled buttons for filters — use the project's `Tabs` or `SegmentedControl` components. |
| **FilterRail on every data view** | Every data-heavy page (Browse, Duplicates, Lint, History) must have a `Rail` + `RailSection` + `RailItem` sidebar. Use the existing `FilterRail` component or compose from Rail primitives. Each rail filter must trigger an API re-fetch (server-side), not client-side filtering. Client-side cross-filter is acceptable when all data is already loaded (e.g. scan diff rows). |
| **FilterRail labels must be translated** | RailItem labels for machine IDs (ruleId, status codes) must use `t(\`ns.${id}.name\`, { defaultValue: id })`. Never display raw kebab-case IDs to users. |
| **Fragment pattern for FilterRail views** | Views with a FilterRail sidebar render as `<> <Rail/> <div content-scroll/> </>`. App.tsx must NOT wrap them in the shared scroll div — move them to a separate branch like DuplicatesView. |
| **Virtualizer: call `measure()` on collapse** | When collapsible groups change the virtual row count, cached `estimateSize` results become stale (old finding sizes applied to header indices). Call `virtualizer.measure()` via `useEffect` keyed on row count + collapsed size to force re-estimation. |
| **Sticky toolbar: translucent backdrop** | Sticky toolbars use `bg-[color-mix(in_srgb,var(--g-canvas)_92%,transparent)] backdrop-blur-[12px]` — not opaque `bg-g-canvas`. Match the BrowseToolbar pattern. |
| **i18n: extract args from English fallback** | When backend embeds dynamic values (KB, paths) in message strings but doesn't persist structured args, use a frontend `extractArgs(finding)` helper with regex to parse values from the English message before passing to `t()`. |
| **English translation UI uses raw fallback** | In English translation views, show the English translation when available and otherwise the raw stored label. Do not hide untranslated non-English values and do not render `translated (raw)` in English mode — incomplete translation coverage must degrade to a visible fallback, not blank or confusing UI. |
| **Localized catalog surfaces must request locale-aware data** | Browse cards, drawers, category dropdowns, global command-palette semantic samples, and tag/category editors that display AI category/tag translations must pass `i18n.language` to catalog/detail APIs. Render-only localization is insufficient when the UI depends on backend-provided `categoryI18n`, `tagsI18n`, category-list translations, or translated facet maps — omitting `lang` leaves parts of the screen stuck in the default language. |
| **Reference animation parity** | When implementing UI from an `animation/` reference, match its component structure, class semantics, spacing, and state-specific layout before tuning styles. Shared pieces like `.lsb-header`, pips, timer, ghost input rotator, recommendation row, and loading visual height are the spec; generic loaders or approximate layouts create visible regressions. |
| **AI metadata from settings** | AI model labels, embedding dimensions, search type, limits, thresholds, and context-strip status text must render from settings/runtime/stat APIs instead of demo literals. Hard-coded values make semantic search UI incorrect when users change provider or model settings. |
| **AI feature gates are workspace-scoped** | Semantic search, language/i18n search, tag/OCR badges, translate/embed controls, and command-palette AI entries must only appear when the active workspace/project scope reports ready AI data. Global SQLite rows from another workspace are not readiness and must not enable the UI. |
| **No `overflow-hidden` with sticky children** | `overflow: hidden/auto/scroll` on a container breaks `position: sticky` for descendants. Remove overflow or use `overflow-clip` when a child needs sticky relative to an outer scroll ancestor. |
| **Callback ref for conditional-render measurement** | When a view has early-return branches (loading/empty), `useEffect(fn, [])` misses the ref target on first render. Use `useCallback` ref + `ResizeObserver` for dynamic measurements like toolbar height for second-level sticky. If the callback ref writes measurements to state, update only when a mounted node's dimensions change; clearing state on the transient `null` callback can create a null/node render loop and hit maximum update depth. |
| **Selection overlays use rendered dimensions** | Group selection boxes, canvas bounds, and similar visual overlays must measure actual DOM size (`offsetWidth`/`offsetHeight` or `ResizeObserver`) instead of relying only on stored widths or card-height constants. Resized and compact cards otherwise extend outside the overlay. |
View on GitHub