Pattern Catalog
Each section documents one design rule: its rationale, the intents it applies to, a conforming example, a violating example, and how to allow the rule when the exemption is intentional.
Rules are checked by ferro design:lint and the design_lint MCP tool. A finding is a Warning by default — it does not block rendering and does not fail CI unless --deny is passed.
page-header
Title: Dashboard pages start with a PageHeader
Rationale: A PageHeader gives every app page a consistent title, breadcrumb, and action-button slot.
Intents: all (applies to any spec using the dashboard or app layout)
Conforming example
{
"$schema": "ferro-json-ui/v2",
"root": "header",
"layout": "dashboard",
"elements": {
"header": { "type": "PageHeader", "props": { "title": "Orders" } },
"table": { "type": "DataTable", "props": { "empty_message": "No orders" } }
},
"design": { "intent": "browse" }
}
Violating example
{
"$schema": "ferro-json-ui/v2",
"root": "table",
"layout": "dashboard",
"elements": {
"table": { "type": "DataTable", "props": { "empty_message": "No orders" } }
},
"design": { "intent": "browse" }
}
How to allow
Add "allow": ["page-header"] to the design object when the layout is intentionally exempt (e.g., embedded frames, custom shell layouts):
{ "design": { "intent": "browse", "allow": ["page-header"] } }
prefer-data-table
Title: Prefer DataTable over raw Table
Rationale: DataTable adds responsive mobile cards and DropdownMenu row actions the raw Table lacks.
Intents: browse
Conforming example
{
"elements": {
"list": { "type": "DataTable", "props": { "empty_message": "No items" } }
}
}
Violating example
{
"elements": {
"list": { "type": "Table" }
}
}
How to allow
{ "design": { "intent": "browse", "allow": ["prefer-data-table"] } }
list-empty-state
Title: List pages define an empty state
Rationale: An empty state with a create CTA turns a blank list into a first-run affordance.
Intents: browse
Conforming example
{
"elements": {
"list": {
"type": "DataTable",
"props": { "empty_message": "No products yet" }
}
}
}
An EmptyState element anywhere in the spec is also conforming:
{
"elements": {
"list": { "type": "DataTable" },
"empty": {
"type": "EmptyState",
"props": { "title": "No products", "action_label": "Add product" }
}
}
}
Violating example
{
"elements": {
"list": { "type": "DataTable" }
}
}
How to allow
{ "design": { "intent": "browse", "allow": ["list-empty-state"] } }
row-actions-grouped
Title: Group row/card actions in an ActionGroup
Rationale: Loose inline buttons per row are inconsistent and crowd small screens; an ActionGroup/DropdownMenu keeps them tidy.
Intents: browse, process
Conforming example
{
"elements": {
"row": {
"type": "Card",
"children": ["actions"]
},
"actions": {
"type": "ActionGroup",
"props": {
"items": [
{ "label": "Edit", "handler": "items.edit" },
{ "label": "Delete", "destructive": true, "handler": "items.destroy" }
]
}
}
}
}
Violating example
{
"elements": {
"row": {
"type": "Card",
"children": ["btn_edit", "btn_delete"]
},
"btn_edit": { "type": "Button", "props": { "label": "Edit" } },
"btn_delete": { "type": "Button", "props": { "label": "Delete" } }
}
}
How to allow
{ "design": { "intent": "browse", "allow": ["row-actions-grouped"] } }
breadcrumb-on-subpages
Title: Create/edit/detail pages carry a Breadcrumb
Rationale: A breadcrumb back to the list page keeps navigation reversible on nested pages.
Intents: collect, focus
Conforming example
{
"elements": {
"header": {
"type": "PageHeader",
"props": {
"title": "New Order",
"breadcrumb": [{ "label": "Orders", "href": "/orders" }]
}
},
"form": { "type": "Form" }
},
"layout": "dashboard"
}
Violating example
{
"elements": {
"header": { "type": "PageHeader", "props": { "title": "New Order" } },
"form": { "type": "Form" }
},
"layout": "dashboard"
}
How to allow
{ "design": { "intent": "collect", "allow": ["breadcrumb-on-subpages"] } }
process-kanban
Title: Status-workflow pages use a KanbanBoard
Rationale: A KanbanBoard with per-column count badges is the canonical view for status workflows.
Intents: process
Conforming example
{
"elements": {
"board": { "type": "KanbanBoard", "props": { "columns": [] } }
},
"design": { "intent": "process" }
}
Violating example
{
"elements": {
"list": { "type": "DataTable", "props": { "empty_message": "No orders" } }
},
"design": { "intent": "process" }
}
How to allow
{ "design": { "intent": "process", "allow": ["process-kanban"] } }
card-actions-in-menu
Title: Kanban card actions belong in the menu, destructive last
Rationale: Consistent action order (detail first, destructive last) inside the ActionGroup prevents mis-clicks on cards.
Intents: process
Conforming example
{
"elements": {
"board": {
"type": "KanbanBoard",
"props": {
"row_actions": [
{ "label": "View details", "handler": "orders.show" },
{ "label": "Cancel", "destructive": true, "handler": "orders.cancel",
"confirm": { "title": "Cancel order?", "tone": "destructive" } }
]
}
}
}
}
Violating example
{
"elements": {
"board": {
"type": "KanbanBoard",
"props": {
"row_actions": [
{ "label": "Cancel", "destructive": true, "handler": "orders.cancel" },
{ "label": "View details", "handler": "orders.show" }
]
}
}
}
}
How to allow
{ "design": { "intent": "process", "allow": ["card-actions-in-menu"] } }
create-separate-page
Title: Entity creation is a dedicated page, not a Modal
Rationale: A separate create/edit page is linkable, refresh-safe, and leaves room for validation feedback.
Intents: collect
Conforming example
{
"elements": {
"form": { "type": "Form" }
},
"design": { "intent": "collect" }
}
Violating example
{
"elements": {
"modal": { "type": "Modal", "children": ["form"] },
"form": { "type": "Form" }
},
"design": { "intent": "collect" }
}
How to allow
{ "design": { "intent": "collect", "allow": ["create-separate-page"] } }
form-default-values
Title: Edit-form fields pre-fill from data
Rationale: On an edit form every field must restore its stored value; a blank field silently discards data on save.
Intents: collect
Conforming example
{
"elements": {
"name": {
"type": "Input",
"props": {
"field": "name",
"default_value": { "$data": "/record/name" }
}
},
"email": {
"type": "Input",
"props": {
"field": "email",
"default_value": { "$data": "/record/email" }
}
}
}
}
Violating example
Edit form detected (one field has a $data default_value), but another field is missing it:
{
"elements": {
"name": {
"type": "Input",
"props": {
"field": "name",
"default_value": { "$data": "/record/name" }
}
},
"email": {
"type": "Input",
"props": { "field": "email" }
}
}
}
How to allow
{ "design": { "intent": "collect", "allow": ["form-default-values"] } }
destructive-confirmation
Title: Destructive actions require confirmation
Rationale: An irreversible action behind a single click is a data-loss hazard; a confirm dialog is the guard.
Intents: all
Conforming example
{
"elements": {
"delete_btn": {
"type": "Button",
"props": { "label": "Delete", "variant": "destructive" },
"action": {
"handler": "items.destroy",
"method": "DELETE",
"confirm": { "title": "Delete item?", "tone": "destructive" }
}
}
}
}
Violating example
{
"elements": {
"delete_btn": {
"type": "Button",
"props": { "label": "Delete", "variant": "destructive" },
"action": { "handler": "items.destroy", "method": "DELETE" }
}
}
}
How to allow
{ "design": { "intent": "collect", "allow": ["destructive-confirmation"] } }
prefer-components
Title: Prefer catalog components over RawHtml
Rationale: UI inside a RawHtml escape hatch is invisible to the design system: tokens, variants, and every other lint rule cannot see it. Each use should be a deliberate, allow-justified exception.
Intents: all
Severity: info — the escape hatch is legitimate; the rule makes it visible, it never fails --deny.
Conforming example
{
"elements": {
"greeting": { "type": "Text", "props": { "content": "Benvenuto" } }
}
}
Violating example
{
"elements": {
"custom_widget": {
"type": "RawHtml",
"props": { "html": { "$data": "/widget_html" } }
}
}
}
How to allow
{ "design": { "intent": "collect", "allow": ["prefer-components"] } }
register-fill-viewport
Title: Register pages must fill the viewport
Rationale: A TileGrid, SelectionPanel, or Numpad outside a fill_viewport spec causes silent whole-page scroll, breaking the register feel.
Intents: all (applies to any spec containing register component types)
Conforming example
{
"$schema": "ferro-json-ui/v2",
"root": "r",
"fill_viewport": true,
"layout": "app",
"elements": {
"r": { "type": "Grid", "props": { "fill": true } }
}
}
Violating example
{
"$schema": "ferro-json-ui/v2",
"root": "r",
"elements": {
"r": { "type": "TileGrid" }
}
}
How to allow
Add "allow": ["register-fill-viewport"] to the design object when the spec is
intentionally not fill-mode (e.g., a product browse page, not a register):
{ "design": { "allow": ["register-fill-viewport"] } }
register-grid-fill
Title: The register-root Grid must set fill:true under fill_viewport
Rationale: A fill_viewport spec whose root Grid lacks fill:true loses per-pane internal scroll — the panes scroll the page instead.
Intents: all (applies to any fill_viewport spec whose root element is a Grid)
Conforming example
{
"$schema": "ferro-json-ui/v2",
"root": "r",
"fill_viewport": true,
"layout": "app",
"elements": {
"r": { "type": "Grid", "props": { "columns": 2, "fill": true } }
}
}
Violating example
{
"$schema": "ferro-json-ui/v2",
"root": "r",
"fill_viewport": true,
"layout": "app",
"elements": {
"r": { "type": "Grid", "props": { "columns": 2 } }
}
}
How to allow
{ "design": { "allow": ["register-grid-fill"] } }
register-selection-present
Title: A TileGrid register needs a SelectionPanel
Rationale: A TileGrid with no SelectionPanel anywhere is an incomplete register — the operator has products but nowhere to accumulate the sale.
Intents: all (applies to any spec containing a TileGrid)
Conforming example
{
"$schema": "ferro-json-ui/v2",
"root": "r",
"fill_viewport": true,
"layout": "app",
"elements": {
"r": { "type": "Grid", "props": { "fill": true } },
"grid": { "type": "TileGrid" },
"cart": { "type": "SelectionPanel" }
}
}
Violating example
{
"$schema": "ferro-json-ui/v2",
"root": "r",
"elements": {
"r": { "type": "TileGrid" }
}
}
How to allow
Add "allow": ["register-selection-present"] when a TileGrid is used in a non-register
context (e.g., a product catalogue browse page with no selection panel):
{ "design": { "allow": ["register-selection-present"] } }
fill-viewport-layout-unknown
Title: fill_viewport requires an app-shell layout
Rationale: The ferro-fill CSS chain only supports the app and dashboard layouts; on any other layout fill_viewport silently degrades to whole-page scroll.
Intents: all (applies to any spec with fill_viewport: true)
Conforming example
{
"$schema": "ferro-json-ui/v2",
"root": "r",
"fill_viewport": true,
"layout": "app",
"elements": {
"r": { "type": "Grid", "props": { "fill": true } }
}
}
Violating example
{
"$schema": "ferro-json-ui/v2",
"root": "r",
"fill_viewport": true,
"layout": "auth",
"elements": {
"r": { "type": "Grid" }
}
}
How to allow
{ "design": { "allow": ["fill-viewport-layout-unknown"] } }
skin-raw-literals
Title: Skin rules must use var(--token) references, not raw literals
Rationale: Raw color/size literals in fjui-* rules bypass the token contract; the skin cannot be rethemed by overriding tokens alone.
Surface: @layer components CSS skin file (checked via ferro design:lint --skin <path>)
Conforming example
@layer components {
.fjui-btn {
color: var(--color-text);
background: var(--color-primary);
}
}
Violating example
@layer components {
.fjui-btn {
color: #1a1a1a; /* raw hex literal — not a token reference */
background: rgb(0,0,0); /* raw rgb() — not a token reference */
}
}
How to fix
Replace every raw color/size literal with a var(--token-name) reference from the token contract. color-mix(in oklab, var(--token) N%, transparent) is also allowed.
skin-interaction-states
Title: Interactive fjui-* rules must define all four interaction states
Rationale: Missing :hover, :focus-visible, :active, or :disabled states silently drop keyboard and pointer affordances for users relying on them.
Surface: @layer components CSS skin file (checked via ferro design:lint --skin <path>)
Interactive prefixes that require all four states: fjui-btn, fjui-input, fjui-select, fjui-textarea, fjui-sidebar__nav-item, fjui-menu-item, fjui-tab, fjui-table__row. Note: fjui-table__row is exempt from :disabled (rows are not form controls).
Conforming example
@layer components {
.fjui-btn {
color: var(--color-text);
&:hover { background: var(--color-surface); }
&:focus-visible { outline: 2px solid var(--color-ring); }
&:active { opacity: 0.85; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
}
Violating example
@layer components {
.fjui-btn {
color: var(--color-text);
&:hover { background: var(--color-surface); }
/* missing :focus-visible, :active, :disabled */
}
}
How to fix
Add the missing &:state { ... } block inside the fjui-* rule body for each missing interaction state.
contrast-lint
Title: Token contrast ratios must meet WCAG floors
Rationale: Text token pairs must achieve >=4.5:1 and UI/non-text pairs >=3:1 in both light and dark modes to meet WCAG 2.1 AA contrast requirements.
Surface: tokens.css file (checked via ferro design:lint --tokens <path>)
Checked pairs (light and dark modes independently):
--color-textvs--color-background— text floor 4.5:1--color-textvs--color-card— text floor 4.5:1--color-primary-foregroundvs--color-primary— text floor 4.5:1--color-ringvs--color-background— non-text floor 3:1--color-ringvs--color-card— non-text floor 3:1
Conforming example
:root {
--color-text: oklch(15% 0 0); /* near-black */
--color-background: oklch(99% 0 0); /* near-white */
/* ratio >> 4.5:1 */
}
Violating example
:root {
--color-text: oklch(60% 0 0);
--color-background: oklch(55% 0 0);
/* ratio ~1.2:1 — below the 4.5:1 text floor */
}
How to fix
Adjust the offending token's lightness in tokens.css until the pair meets or exceeds its floor ratio. Rerun ferro design:lint --tokens <path> --deny to confirm.
skin-border-or-shadow
Title: A fjui-* rule must not declare both a visible border and a non-none box-shadow
Rationale: Flat surfaces use border only; overlays use shadow only. Mixing both on the same element blurs the elevation hierarchy and creates visual noise (LANG-04).
Surface: @layer components CSS skin file (checked via ferro design:lint --skin <path>)
Exceptions (not flagged):
border: noneorbox-shadow: none— explicit opt-out of the propertybox-shadowthat appears only inside a&:focus-visible { }nested block (focus rings)
Conforming example
@layer components {
/* flat surface — border only */
.fjui-card {
border: 1px solid var(--color-border);
/* NO box-shadow */
}
/* overlay — shadow only */
.fjui-menu {
box-shadow: var(--shadow-md);
/* NO border */
}
/* focus ring exemption — shadow inside :focus-visible is allowed */
.fjui-input {
border: 1px solid var(--color-border);
&:focus-visible {
box-shadow: 0 0 0 2px var(--color-ring);
}
}
}
Violating example
@layer components {
.fjui-card {
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md); /* both border AND shadow — violation */
}
}
How to fix
Choose one elevation signal per surface type. Remove box-shadow from flat surfaces (cards, sidebars, headers) or remove border from overlay surfaces (dropdowns, modals, toasts). If the shadow is a focus ring, move it inside &:focus-visible { }.