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