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.


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"] } }

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-text vs --color-background — text floor 4.5:1
  • --color-text vs --color-card — text floor 4.5:1
  • --color-primary-foreground vs --color-primary — text floor 4.5:1
  • --color-ring vs --color-background — non-text floor 3:1
  • --color-ring vs --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: none or box-shadow: none — explicit opt-out of the property
  • box-shadow that 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 { }.