diff --git a/.claude/skills/ui-architect/SKILL.md b/.claude/skills/ui-architect/SKILL.md index 9637d9cdd5..baaed00b05 100644 --- a/.claude/skills/ui-architect/SKILL.md +++ b/.claude/skills/ui-architect/SKILL.md @@ -36,7 +36,9 @@ description: >- side panels become drawers that open from their own row, row actions fold into a kebab, and popups fit the viewport. It also settles the recurring structural decisions: use OTable for any tabular data, follow the - view → service → Vuex/local-ref layering for fetching list data, choose the + TanStack Query layering for server data (a declared queryOptions() per read + on its module's staleTime tier, mutationOptions() writes that invalidate, + refresh buttons that force every read on the view, no Vuex copies), choose the right form container (ConfirmDialog vs ODialog vs ODrawer vs a full in-page view) by the weight of the interaction, and build every validated form with OForm + a colocated Zod schema (single-source-of-truth name-bound fields, no @@ -45,7 +47,7 @@ description: >- validate, or restyle any screen, component, header, table, list, dialog, drawer, form, field, or panel in the web frontend, asks to make any of them work on mobile / phone / tablet / small screens (responsive), or asks where a - form/table/fetch should live, how to validate a form, how to add a keyboard + form/table/fetch should live, how to cache or refresh server data, how to validate a form, how to add a keyboard shortcut, how to build a new reusable/common O2 component when nothing existing fits (create one in web/src/lib instead of assembling divs and classes), whether something belongs in a dialog or a drawer, or where a new page should be @@ -557,13 +559,13 @@ and each domain has its own reference below. | Decision | The rule | Detail | | --- | --- | --- | +| **Server data (fetch & cache)** | Every read is a declared `queryOptions()` in `services/.queries.ts` (reuse the existing one if the list is already declared), keyed with `orgKey`, on its **module's** `staleTime` tier from `cachePolicy.ts`; components `useQuery` it (rows as a `computed`) — never `http`/axios, never a Vuex copy of a server list. Writes are `mutationOptions()` with `meta.invalidates`. A user refresh forces **every** read on the view; mount, paging and search read the cache. | [data-fetching](references/data-fetching.md) | | **Tabular data** | `OTable` + `OTableColumnDef[]`; client-side pagination unless the backend paginates a set too large to fetch whole | [core-controls-table](references/core-controls-table.md) | | **Charts / graphs** | **Every data chart renders through the shared dashboard engine — never mount a charting lib in a feature page.** Time-series, category, scatter, geo/map, gauge, pie → **`PanelSchemaRenderer`** (`web/src/components/dashboards/PanelSchemaRenderer.vue`) with a panel schema: it runs the query, applies the app's unit/theme/annotation formatting, and owns the loading/error ladder. **Banned in feature code:** `echarts.init` / a raw `` / ApexCharts / D3 / Chart.js / a hand-rolled `` or `` plot. The low-level **`panels/ChartRenderer.vue`** (raw ECharts option) is the ONLY sanctioned escape hatch, and ONLY when you need chart-`@click` forwarding `PanelSchemaRenderer` doesn't re-emit — annotate the site with why, and convert once the schema renderer forwards clicks. **Not charts** (do NOT force these through the renderer): in-row trend lines are **`OSparkline`**, single-value share bars are **`OProgressBar`**, in-cell data bars are the table's **`ODataBarCell`**, and a decorative topology/diagram is bespoke SVG. | [core-display](references/core-display.md) | | **Whole-page layout** | **Every routed view is a `OPageLayout`.** It's the ONE page component — it owns the full-height column, the header (from `:title`/`:icon`/`:subtitle`/`:back` props + `#actions`/`#header-tabs`, the latter needing **`tabs-below`** to land in row 2 instead of inline), an optional `#subnav` strip, an optional `#sidebar` rail (fixed or `resizable`), and the body's inset. You plug in data; there's no place to hand-roll a padded `
`. Body is inset to the page-edge grid by default — pass **`bleed`** for a full-bleed body (an `OTable`, a chart, a `router-view` shell), or **`constrained`** for a centered reading column (forms). The `#header` slot is a rare escape hatch only. | [page-recipes](references/page-recipes.md) | | **Content inset** | `OPageLayout` already insets the body. Anywhere else (a panel, a dialog section, one tab's content) wrap it in **`OContent`** (bakes the one `px-page-edge` grid line, the primitive `OPageLayout` uses internally) instead of hand-picking `px-2`/`px-4`/`p-2.5`; pass `bleed` (or `bleed-x`/`bleed-y`) for full-bleed content that owns its own edge — same escape-hatch idea as `ODrawer`/`ODialog` `bleed`. Never hand-roll a content inset. | [conventions](references/conventions.md) | | **Tab strips** | an `OTabs` strip needs **no** horizontal wrapper padding — the first tab's label self-aligns to the `px-page-edge` grid, so it lines up with the `OContent` body below it. Put the strip's bottom divider on the strip (`border-b`) and give it no `px-*`; wrapping a tab strip in `px-page-edge` double-insets the labels. | [conventions](references/conventions.md) | | **Listing toolbar** | every list carries three affordances — search + filters (`#toolbar`), refresh (`#toolbar-trailing`), and the auto-injected column-visibility toggle; empty state is one `OEmptyState` with `:filtered`, in the `#empty` slot only, plus `:forbidden` on the table so a 403 shows "You don't have access" instead of "create your first…" | [page-recipes](references/page-recipes.md) | -| **Data fetching** | view → domain service (`src/services`, via the `http.ts` wrapper) → Vuex (shared/cached) or local `ref` (ephemeral); never call `http`/axios from a component | [conventions](references/conventions.md) | | **Form container** | confirm → `ConfirmDialog`; short form → `ODialog`; tall or contextual form → `ODrawer`; primary multi-section flow → a full in-page view. Use `ODialog` / `ODrawer` for these | [conventions](references/conventions.md) | | **Form validation** | `OForm` + a colocated Zod `
.schema.ts`; fields are `OForm*` bound **only by `name=`** (no `v-model`/`ref` mirror, no `formData`); submit + loading automatic; payload built with explicit keys; field arrays use `:key="index"` | [forms-validation](references/forms-validation.md) | | **New page in nav** | a route **+ exactly one** surface (rail item / flyout child / Settings / IAM sub-page) **+** an env/role gate — the route condition, the nav-entry gate, and the SectionRail `visible` all express the same rule | [navigation-menus](references/navigation-menus.md) | @@ -653,6 +655,15 @@ considering the UI done: (`navGroups.ts` / `linksList` / `settingsItems` / SectionRail). A module showing one glyph in the rail and another in its header reads as two places — see [navigation-menus](references/navigation-menus.md#icon-parity). +- [ ] Any data the page reads is a `queryOptions()` in + `services/.queries.ts`, consumed with `useQuery` — not a service + call with a hand-rolled `loading` ref. Durations come from + `cachePolicy.ts` (the module's tier), never a bare number; no `persister`, + no per-query `gcTime`. Writes are `mutationOptions()` declaring + `meta.invalidates`; a component never calls `invalidateQueries` itself. + Refresh (button, `r`, section icons, Retry) forces **every** read on the + view through a named handler. See + [references/data-fetching.md](references/data-fetching.md). - [ ] Every interactive control is an O2 component if one exists in `web/src/lib` — no bare HTML controls or third-party primitives with an O2 equivalent. - [ ] A self-contained/repeated UI element with no matching component was @@ -697,8 +708,9 @@ considering the UI done: (`config.isEnterprise` / `config.isCloud` / `zoConfig.*`), with the route, nav-entry gate, and SectionRail `visible` all in sync (see [navigation-menus.md](references/navigation-menus.md)). -- [ ] Data fetched through a domain service (`src/services`), not raw `http` in - the component; shared data in Vuex, ephemeral data in local refs. +- [ ] Server data reaches the component through a declared query (never raw + `http`), and no server list is copied into Vuex or a load-once map; + ephemeral view state stays in local refs. - [ ] Form container matches weight: confirm → `ConfirmDialog`, short form → `ODialog`, large/contextual form → `ODrawer`, primary multi-section flow → full page. diff --git a/.claude/skills/ui-architect/references/calm-signal.md b/.claude/skills/ui-architect/references/calm-signal.md index d48b17b180..4157e38486 100644 --- a/.claude/skills/ui-architect/references/calm-signal.md +++ b/.claude/skills/ui-architect/references/calm-signal.md @@ -14,15 +14,15 @@ Apply it in three steps. ## Step 1 — Name the screen's primary signal -Every screen answers one question. Colour *that*; keep the rest quiet. Which +Every screen answers one question. Colour _that_; keep the rest quiet. Which signal earns the colour changes by page type: -| Archetype | Examples | Primary signal → colour | -| --- | --- | --- | -| **Monitoring** | Alerts, Incidents, RUM errors, Synthetics | **state / severity / health** — active/paused/failed, firing, recency | -| **Catalog** | Dashboards, Streams, Functions, Pipelines | **organise & orient** — category/type, owner, freshness, size/usage, favourites | -| **Access** | Users, IAM, Roles, Service accounts | **role / permission** — privilege runs hot→cool, plus active/invited status | -| **Forms & settings** | creation flows, editors, org/stream settings | **progress & validity** — where am I, is it right, what still needs me | +| Archetype | Examples | Primary signal → colour | +| -------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | +| **Monitoring** | Alerts, Incidents, RUM errors, Synthetics | **state / severity / health** — active/paused/failed, firing, recency | +| **Catalog** | Dashboards, Streams, Functions, Pipelines | **organise & orient** — category/type, owner, freshness, size/usage, favourites | +| **Access** | Users, IAM, Roles, Service accounts | **role / permission** — privilege runs hot→cool, plus active/invited status | +| **Forms & settings** | creation flows, editors, org/stream settings | **progress & validity** — where am I, is it right, what still needs me | Don't bolt "status" onto a screen that has none. A Dashboards list has no firing/paused state — its signal is category + recency + ownership. @@ -61,27 +61,28 @@ All token-backed and dark-mode-safe. Reuse these before inventing anything. exception highlight** via `row-class`. The rail and the wash are two different strengths of the same signal, and the rail is the default: - | | Rail (`getRowStyle`) | Wash (`row-class`) | - | --- | --- | --- | - | Cost | a few px at the row edge | ~the whole row | - | Use for | **every** state, always | only the two cases below | + | | Rail (`getRowStyle`) | Wash (`row-class`) | + | ------- | ------------------------ | ------------------------ | + | Cost | a few px at the row edge | ~the whole row | + | Use for | **every** state, always | only the two cases below | A full-row wash is the loudest thing on a list, so it earns its place only when **all three** hold: the state means **act now** (not merely "not green"), it is **rare in a healthy system**, and **the row is the unit of action**. In practice that leaves exactly two washes: - - `!bg-status-error-bg` — failing/errored/offline. *Alerts* failed, *Pipelines* - errored, *Nodes* offline, *Synthetics* failed. + - `!bg-status-error-bg` — failing/errored/offline. _Alerts_ failed, _Pipelines_ + errored, _Nodes_ offline, _Synthetics_ failed. - `!bg-surface-panel` — paused/disabled. This one is **de-emphasis, not alarm**: the row is deliberately inert, so it recedes rather than shouts. Everything else keeps a clean row and reads from the rail — including states that are "bad but not urgent": **degraded/warning** (worth noticing, not worth acting on this second), **stale**, **unknown**, **never-ran**. And whatever the - state means, if it is **common** the wash is wrong regardless: *Streams* drops it + state means, if it is **common** the wash is wrong regardless: _Streams_ drops it entirely, because "never ingested" and "quiet for a day" describe a large share of rows in a normal org and a table where most rows are tinted signals nothing. Pages with no failure state at all — Users, Roles, Dashboards — never wash. + - **Recency** — `OTimeCell` `mode="relative"` (`"3 min ago"`) with a hot/warm/ cold dot, instead of a raw timestamp column. - **People** — `OUserCell` for owner/author columns. @@ -100,12 +101,12 @@ Once a strip is `selectable`, four rules keep every strip behaving identically - **Order attention-first, left → right.** The most critical / attention-worthy tile is leftmost, descending to the calm/inert ones — e.g. `failed → recent → - active → paused`, `P1 → P2 → P3 → P4`, `degraded → paused → active → draft → - archived`. This mirrors the row rail's "critical colour on the left edge": what +active → paused`, `P1 → P2 → P3 → P4`, `degraded → paused → active → draft → +archived`. This mirrors the row rail's "critical colour on the left edge": what needs attention lives on the left, everywhere. - **The "All" / "Total" tile is LAST, stays CLICKABLE, and is never highlighted.** It only clears the facet; it is never itself the active tile. Wire `:selected-key` - to the *raw filter value* (`null` / `"all"` when unfiltered) so nothing shows the + to the _raw filter value_ (`null` / `"all"` when unfiltered) so nothing shows the ring while viewing everything — never fall back to selecting "All" as the default. **Never put `selectable: false` on it** — that is the one wrong way to express "never highlighted": it turns the tile into a plain `
`, so clicking it does @@ -120,7 +121,7 @@ Once a strip is `selectable`, four rules keep every strip behaving identically see Step 3. - **A strip in `#subheader` is a CLAIM ABOUT THE ROWS — scope decides position.** Inside the table frame, a strip promises "these numbers describe the list below", - so it must be computed from the same filtered set *and* be facet-clickable. Before + so it must be computed from the same filtered set _and_ be facet-clickable. Before writing one, check the fetch: **if the list is server-paginated you only hold one page**, so page-local sums are not totals — never sum the visible page and label it "Total". When the numbers genuinely can't meet the promise (server pagination, @@ -135,8 +136,7 @@ Once a strip is `selectable`, four rules keep every strip behaving identically - **`ODataBarCell` needs the whole set — client-paginated tables only.** Its bar is a share of the `max` the caller computes over the rendered rows, so on a **server-paginated** table it silently means "biggest on this page": the scale - changes as you page, and the same stream draws a different bar on page 1 and page - 4. On such a table drop the bars and let the (sortable) numbers rank the rows — + changes as you page, and the same stream draws a different bar on page 1 and page 4. On such a table drop the bars and let the (sortable) numbers rank the rows — right-aligned + `tabular-nums` already scans fine, and the stray part-width underlines read as artefacts rather than data. Same test as the strip: can this mark be computed from data you actually hold? @@ -153,7 +153,7 @@ strip feeds a drill-down. ### Before you colour a state, check how it CLEARS -A State column is a claim about *now*. Read the write path of whatever field backs +A State column is a claim about _now_. Read the write path of whatever field backs it and confirm something resets it — an expiry/retention job, a success write, a status transition. A sticky error field (written on failure, never cleared) pins a row to "Errored" forever and the column becomes noise within a week. Pipelines' @@ -182,20 +182,20 @@ Colour only earns attention if most of the screen stays quiet: each tile to three questions: does the number **vary**, does someone **act** on it, and is it **not already on screen**? Tiles that fail are noise dressed as signal: - - *structurally constant* — "System accounts" is 1 in almost every org, so the + - _structurally constant_ — "System accounts" is 1 in almost every org, so the tile is a label with a number stuck to it; - - *almost always zero* — "New this week" on a list that gains an item a quarter; - - *derivable from its neighbours* — "In use" beside "Unused" and "Total"; - - *already in the footer* — `footerTitle` renders "N Dashboards" under every + - _almost always zero_ — "New this week" on a list that gains an item a quarter; + - _derivable from its neighbours_ — "In use" beside "Unused" and "Total"; + - _already in the footer_ — `footerTitle` renders "N Dashboards" under every table, so a Total tile alone is not a reason to have a strip. - A page whose only candidates fail these gets **no strip** — keep the per-row - signals (relative recency, a state rail, a count column) and stop. Dashboards, - Service Accounts and Roles all ended up here: pages where a strip added pixels - and no information. Roles is the clearest case — "Unused" is just the member - column sorted ascending, so two tiles restated what the rows already said. **A - count column plus sorting usually beats a strip**; reach for a strip only when - the page has a real distribution to summarise (Users across roles, monitors - across health) *and* the tiles double as the facet. + A page whose only candidates fail these gets **no strip** — keep the per-row + signals (relative recency, a state rail, a count column) and stop. Dashboards, + Service Accounts and Roles all ended up here: pages where a strip added pixels + and no information. Roles is the clearest case — "Unused" is just the member + column sorted ascending, so two tiles restated what the rows already said. **A + count column plus sorting usually beats a strip**; reach for a strip only when + the page has a real distribution to summarise (Users across roles, monitors + across health) _and_ the tiles double as the facet. - **Highlight exceptions, not the norm.** Tint the failed/paused rows; leave the healthy majority clean. A table where every row is coloured signals nothing. And if a page has no true failure state (a catalog list), the calm answer is a @@ -204,19 +204,19 @@ Colour only earns attention if most of the screen stays quiet: data" is a `—`, not a wall of zeros. (`OStatCard` does this.) - **State is border/colour, not fills.** Selected/hover on interactive tiles use an accent **border**, not a grey/blue background wash. No hover shadows. -- **Semantic colour ≠ brand accent.** Green/amber/red carry *meaning* (health); - the brand teal carries *structure/selection*. Don't mix the two roles. +- **Semantic colour ≠ brand accent.** Green/amber/red carry _meaning_ (health); + the brand teal carries _structure/selection_. Don't mix the two roles. - **One primary action.** In forms, exactly one brand-coloured primary (Save); destructive in red; everything else neutral. - **No layout shift.** Reserve space for anything that streams in (a stat card - renders the same box loaded or not; a proportion-bar *track* is always drawn). + renders the same box loaded or not; a proportion-bar _track_ is always drawn). --- ## Per-archetype quick recipe - **Monitoring** → `OStatStrip` (state counts, filter tiles) in `#subheader`; - `OTag` status chips; row rail + exception tint; relative recency. *(Alerts.)* + `OTag` status chips; row rail + exception tint; relative recency. _(Alerts.)_ - **Catalog** → `OStatStrip` counting totals (Total · Folders · Favourites · Updated-this-week, `max` optional); category `OTag` (folder/type/language); `OUserCell` owner; relative "updated"; a colour-filled favourite. diff --git a/.claude/skills/ui-architect/references/component-catalog.md b/.claude/skills/ui-architect/references/component-catalog.md index f5f0a2ba03..c4fd07afe3 100644 --- a/.claude/skills/ui-architect/references/component-catalog.md +++ b/.claude/skills/ui-architect/references/component-catalog.md @@ -10,21 +10,21 @@ markup — don't guess a prop name. ### Reference files -| File | Covers | -| --- | --- | -| [references/core-display.md](core-display.md) | Badge/Tag/DimensionChip, Card, Code, Collapsible, EmptyState, Icon, Separator, Shortcut, **Text (typography)**, VirtualScroll | -| [references/core-controls-table.md](core-controls-table.md) | **Button**/ButtonGroup, Navbar, RefreshButton, Splitter, ToggleGroup, **Table** (+ cell renderers) | -| [references/forms-inputs.md](forms-inputs.md) | Input/Textarea, Select, Combobox, SearchInput, Checkbox, Radio, Switch, OptionGroup | -| [references/forms-specialized.md](forms-specialized.md) | **Form + useOForm**, Color, Date, DateTimeRange, Time, File, Range, Slider | -| [references/forms-validation.md](forms-validation.md) | **Validating a form: OForm + Zod schema, binding rules, submit/loading, conditional rendering, field arrays, testing** | -| [references/keyboard-shortcuts.md](keyboard-shortcuts.md) | **Keyboard shortcuts: registry, `useShortcut`/`useShortcuts`, display via `OShortcut`/`shortcut-id`, cheatsheet** | -| [references/creating-components.md](creating-components.md) | **Building a NEW O2 component: lib vs components, folder contract, families, headless-first (reka-ui), no-UI-prop-leakage, strict TS, tokens, form wrappers, workflow, testing** | -| [references/design-tokens.md](design-tokens.md) | **Design tokens: using `--color-*`, the token files, registering a new token, the `--o2-*` ban + full `--o2-*` → `--color-*` migration map** | -| [references/page-recipes.md](page-recipes.md) | **Whole-page layouts: the listing/table page (full-height flush skeleton, mandatory search · refresh · column-visibility toggle, filtered-vs-first-run empty state) and the detail/editor page** | -| [references/navigation-menus.md](navigation-menus.md) | **Registering a new page in navigation: the route composables, the left-rail item (`linksList`), Settings/IAM sub-pages (`SectionRail`), hover-flyout children (`navGroups.ts`), and cloud/enterprise/RBAC gating** | -| [references/overlay-navigation.md](overlay-navigation.md) | Dialog, Drawer, Dropdown, Popover, Tooltip · Pagination, Stepper, Tabs | -| [references/responsive.md](responsive.md) | **Phones & tablets with an unchanged laptop view: breakpoints + `useBreakpoint`, one-row header/toolbar/stat chrome, rails → anchored drawers, row-action kebab, popup sizing, clipping and touch gotchas, verification** | -| [references/feedback-data.md](feedback-data.md) | Banner, Toast (+ useToast), Spinner, Skeleton, InnerLoading · ProgressBar, Timeline, Tree · FieldList | +| File | Covers | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [references/core-display.md](core-display.md) | Badge/Tag/DimensionChip, Card, Code, Collapsible, EmptyState, Icon, Separator, Shortcut, **Text (typography)**, VirtualScroll | +| [references/core-controls-table.md](core-controls-table.md) | **Button**/ButtonGroup, Navbar, RefreshButton, Splitter, ToggleGroup, **Table** (+ cell renderers) | +| [references/forms-inputs.md](forms-inputs.md) | Input/Textarea, Select, Combobox, SearchInput, Checkbox, Radio, Switch, OptionGroup | +| [references/forms-specialized.md](forms-specialized.md) | **Form + useOForm**, Color, Date, DateTimeRange, Time, File, Range, Slider | +| [references/forms-validation.md](forms-validation.md) | **Validating a form: OForm + Zod schema, binding rules, submit/loading, conditional rendering, field arrays, testing** | +| [references/keyboard-shortcuts.md](keyboard-shortcuts.md) | **Keyboard shortcuts: registry, `useShortcut`/`useShortcuts`, display via `OShortcut`/`shortcut-id`, cheatsheet** | +| [references/creating-components.md](creating-components.md) | **Building a NEW O2 component: lib vs components, folder contract, families, headless-first (reka-ui), no-UI-prop-leakage, strict TS, tokens, form wrappers, workflow, testing** | +| [references/design-tokens.md](design-tokens.md) | **Design tokens: using `--color-*`, the token files, registering a new token, the `--o2-*` ban + full `--o2-*` → `--color-*` migration map** | +| [references/page-recipes.md](page-recipes.md) | **Whole-page layouts: the listing/table page (full-height flush skeleton, mandatory search · refresh · column-visibility toggle, filtered-vs-first-run empty state) and the detail/editor page** | +| [references/navigation-menus.md](navigation-menus.md) | **Registering a new page in navigation: the route composables, the left-rail item (`linksList`), Settings/IAM sub-pages (`SectionRail`), hover-flyout children (`navGroups.ts`), and cloud/enterprise/RBAC gating** | +| [references/overlay-navigation.md](overlay-navigation.md) | Dialog, Drawer, Dropdown, Popover, Tooltip · Pagination, Stepper, Tabs | +| [references/responsive.md](responsive.md) | **Phones & tablets with an unchanged laptop view: breakpoints + `useBreakpoint`, one-row header/toolbar/stat chrome, rails → anchored drawers, row-action kebab, popup sizing, clipping and touch gotchas, verification** | +| [references/feedback-data.md](feedback-data.md) | Banner, Toast (+ useToast), Spinner, Skeleton, InnerLoading · ProgressBar, Timeline, Tree · FieldList | > **Headless vs form-bound.** Most form controls come as a pair: `OInput` > (headless, `v-model` only) and `OFormInput` (adds label / error / required / @@ -33,57 +33,57 @@ markup — don't guess a prop name. ### Scenario → component -| I need to… | Use | Reference | -| --- | --- | --- | -| Trigger an action (submit, add, open) | `OButton` (`OButtonGroup` for a set) | core-controls-table | -| Show a page/module header | `OPageHeader` (rule 1) | — | -| Render tabular data | `OTable` + `OTableColumnDef[]` | core-controls-table | -| Show a status label / count / tag | `OBadge`, `OTag`, `ODimensionChip` | core-display | -| Group content in a surface | `OCard` (+ `OCardSection`, `OCardActions`) | core-display | -| Body / heading / muted text | `OText` (pick the `variant`) | core-display | -| Show an icon | `OIcon` (`name` from the icon registry) | core-display | -| A divider / hairline | `OSeparator` | core-display | -| Show code / a code block | `OCode`, `OCodeBlock` | core-display | -| Expand/collapse a section | `OCollapsible` | core-display | -| Empty "no data" state | `OEmptyState` | core-display | -| Render a huge list performantly | `OVirtualScroll` | core-display | -| Single-line / multiline text entry | `OInput` / `OTextarea` (`OForm*` in a form) | forms-inputs | -| Pick one/many from a fixed list | `OSelect` (+ `OSelectItem`) | forms-inputs | -| Typeahead / free-entry combobox | `OCombobox` | forms-inputs | -| A search box | `OSearchInput` | forms-inputs | -| Boolean checkbox / group | `OCheckbox` / `OCheckboxGroup` | forms-inputs | -| Mutually exclusive choice | `ORadioGroup` (few) / `OSelect` (many) | forms-inputs | -| Instant on/off toggle | `OSwitch` | forms-inputs | -| Segmented control | `OToggleGroup` | core-controls-table | -| Filter strip that must fit a phone row | `OToggleGroup mobile-dropdown` | **responsive** | -| Side rail / field list on a phone | `ODrawer` with `anchor` (or `OPageLayout #sidebar` / `FolderList`) | **responsive** | -| Secondary header actions on a phone | `OPageHeader`/`OPageLayout` `#actions-overflow` | **responsive** | -| Build a validated form | `OForm` + Zod schema (binding rules) | **forms-validation** | -| Read form state for conditional rendering | `useOForm` (owner) / `inject`+`useStore` (child) | forms-validation | -| Add repeatable/dynamic field rows | `z.array` + indexed `name`, `:key="index"` | forms-validation | -| Pick a color | `OColor` | forms-specialized | -| Pick a date / date-time range / time | `ODate` / `ODateTimeRange` / `OTime` | forms-specialized | -| Upload a file | `OFile` | forms-specialized | -| Numeric slider / range | `OSlider` / `ORange` | forms-specialized | -| Modal task / short form | `ODialog` (see form-container rule) | overlay-navigation | -| Side panel / large or contextual form | `ODrawer` | overlay-navigation | -| Menu of actions | `ODropdown` (+ `ODropdownItem`) | overlay-navigation | -| Freeform floating content | `OPopover` | overlay-navigation | -| Hover hint | `OTooltip` | overlay-navigation | -| Switch between content sections | `OTabs` (+ `OTab`/`OTabPanel`); `ORouteTab` for route-driven | overlay-navigation | -| Multi-step wizard | `OStepper` (+ `OStep`) | overlay-navigation | -| Paginate a list | `OPagination` | overlay-navigation | -| Persistent inline message | `OBanner` | feedback-data | -| Transient notification (replaces `q-notify`) | `useToast()` (+ `OToastProvider` at root) | feedback-data | -| Loading state | `OSkeleton` (placeholder) / `OSpinner` (bare) / `OInnerLoading` (over a container) | feedback-data | -| Progress indicator | `OProgressBar` | feedback-data | -| Event timeline | `OTimeline` (+ `OTimelineItem`) | feedback-data | -| Hierarchical / tree data | `OTree` (+ `OTreeNode`) | feedback-data | -| Label–value detail rows | `OFieldList` (+ `OFieldRow`, `OFieldLabel`) | feedback-data | -| Confirm / destructive prompt | `ConfirmDialog` + `useConfirmDialog` (app-level, `@/components` + `@/composables`) | — | -| Add a keyboard shortcut | registry entry + `useShortcuts([{ id, handler }])`; display via `OShortcut`/`shortcut-id` | keyboard-shortcuts | -| Build a NEW component (nothing fits) | new `O*` in `web/src/lib` (generic) or named component in `web/src/components` (app-specific) — never inline classes | **creating-components** | -| Add a new page to the nav / menu / settings sub-menu | route composable + `linksList` / `navGroups.ts` / `settingsItems`, gated by env/role | **navigation-menus** | +| I need to… | Use | Reference | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| Trigger an action (submit, add, open) | `OButton` (`OButtonGroup` for a set) | core-controls-table | +| Show a page/module header | `OPageHeader` (rule 1) | — | +| Render tabular data | `OTable` + `OTableColumnDef[]` | core-controls-table | +| Show a status label / count / tag | `OBadge`, `OTag`, `ODimensionChip` | core-display | +| Group content in a surface | `OCard` (+ `OCardSection`, `OCardActions`) | core-display | +| Body / heading / muted text | `OText` (pick the `variant`) | core-display | +| Show an icon | `OIcon` (`name` from the icon registry) | core-display | +| A divider / hairline | `OSeparator` | core-display | +| Show code / a code block | `OCode`, `OCodeBlock` | core-display | +| Expand/collapse a section | `OCollapsible` | core-display | +| Empty "no data" state | `OEmptyState` | core-display | +| Render a huge list performantly | `OVirtualScroll` | core-display | +| Single-line / multiline text entry | `OInput` / `OTextarea` (`OForm*` in a form) | forms-inputs | +| Pick one/many from a fixed list | `OSelect` (+ `OSelectItem`) | forms-inputs | +| Typeahead / free-entry combobox | `OCombobox` | forms-inputs | +| A search box | `OSearchInput` | forms-inputs | +| Boolean checkbox / group | `OCheckbox` / `OCheckboxGroup` | forms-inputs | +| Mutually exclusive choice | `ORadioGroup` (few) / `OSelect` (many) | forms-inputs | +| Instant on/off toggle | `OSwitch` | forms-inputs | +| Segmented control | `OToggleGroup` | core-controls-table | +| Filter strip that must fit a phone row | `OToggleGroup mobile-dropdown` | **responsive** | +| Side rail / field list on a phone | `ODrawer` with `anchor` (or `OPageLayout #sidebar` / `FolderList`) | **responsive** | +| Secondary header actions on a phone | `OPageHeader`/`OPageLayout` `#actions-overflow` | **responsive** | +| Build a validated form | `OForm` + Zod schema (binding rules) | **forms-validation** | +| Read form state for conditional rendering | `useOForm` (owner) / `inject`+`useStore` (child) | forms-validation | +| Add repeatable/dynamic field rows | `z.array` + indexed `name`, `:key="index"` | forms-validation | +| Pick a color | `OColor` | forms-specialized | +| Pick a date / date-time range / time | `ODate` / `ODateTimeRange` / `OTime` | forms-specialized | +| Upload a file | `OFile` | forms-specialized | +| Numeric slider / range | `OSlider` / `ORange` | forms-specialized | +| Modal task / short form | `ODialog` (see form-container rule) | overlay-navigation | +| Side panel / large or contextual form | `ODrawer` | overlay-navigation | +| Menu of actions | `ODropdown` (+ `ODropdownItem`) | overlay-navigation | +| Freeform floating content | `OPopover` | overlay-navigation | +| Hover hint | `OTooltip` | overlay-navigation | +| Switch between content sections | `OTabs` (+ `OTab`/`OTabPanel`); `ORouteTab` for route-driven | overlay-navigation | +| Multi-step wizard | `OStepper` (+ `OStep`) | overlay-navigation | +| Paginate a list | `OPagination` | overlay-navigation | +| Persistent inline message | `OBanner` | feedback-data | +| Transient notification (replaces `q-notify`) | `useToast()` (+ `OToastProvider` at root) | feedback-data | +| Loading state | `OSkeleton` (placeholder) / `OSpinner` (bare) / `OInnerLoading` (over a container) | feedback-data | +| Progress indicator | `OProgressBar` | feedback-data | +| Event timeline | `OTimeline` (+ `OTimelineItem`) | feedback-data | +| Hierarchical / tree data | `OTree` (+ `OTreeNode`) | feedback-data | +| Label–value detail rows | `OFieldList` (+ `OFieldRow`, `OFieldLabel`) | feedback-data | +| Confirm / destructive prompt | `ConfirmDialog` + `useConfirmDialog` (app-level, `@/components` + `@/composables`) | — | +| Add a keyboard shortcut | registry entry + `useShortcuts([{ id, handler }])`; display via `OShortcut`/`shortcut-id` | keyboard-shortcuts | +| Build a NEW component (nothing fits) | new `O*` in `web/src/lib` (generic) or named component in `web/src/components` (app-specific) — never inline classes | **creating-components** | +| Add a new page to the nav / menu / settings sub-menu | route composable + `linksList` / `navGroups.ts` / `settingsItems`, gated by env/role | **navigation-menus** | If a scenario isn't covered by anything above, re-check the reference files before assuming a component is missing — the list is complete as of this skill's diff --git a/.claude/skills/ui-architect/references/conventions.md b/.claude/skills/ui-architect/references/conventions.md index ffcc3d8ce3..b8fe6360e9 100644 --- a/.claude/skills/ui-architect/references/conventions.md +++ b/.claude/skills/ui-architect/references/conventions.md @@ -33,7 +33,7 @@ and [references/page-recipes.md](page-recipes.md). background, no border). Every card must carry explicit surface classes, e.g. `bg-card-glass-bg rounded-default border border-border-default` (the AI Observability panel family) or `bg-surface-base border border-border-default - rounded-default` (synthetics). Copy the class string from the sibling family +rounded-default` (synthetics). Copy the class string from the sibling family verbatim. - **A fixed set of stacked panels must not make the page scroll.** When a page is N cards tall (dashboards, signal panels), give it the full-height split: @@ -98,13 +98,14 @@ looking for one to reshape a component, that's the signal you need a new The visual of an O2 component is produced by its own `variant`/`size`, never by patching it from outside. Banned on an O2 component: -| Banned | Why | Instead | -| --- | --- | --- | -| `style="color: #fff; padding: 10px"` | hardcoded colors/px, bypasses tokens | use `variant` / `size` / component design | -| `class="tw:px-2 tw:text-sm"` (`tw:` prefix) | prefix removed — `tw:*` doesn't resolve | use component `variant`/`size` props | -| `