Design Reference · Phase 2

Admin UI
Standards

Canonical patterns for seven recurring UI structures in the Sixhands admin prototype. All examples use live design tokens. Follow these specs exactly — no one-offs.

Inline Alert Top Nav Bar PageHead + Actions Table List Detail Panel Dashboard
01

Inline Alert

Component: InlineAlert · File: shared-blocks.jsx · Props: kind, children, action?

All kinds — live rendering
info — Contextual information visible to real users. POS constraints, export limits, read-only scope explanations.
Learn more →
warn — Warnings requiring attention before acting. Billing issues, destructive action confirmations, MFA off.
error — Active error state. Form failures, sync errors, permission blocks. role="alert" set automatically.
success — Persistent confirmation after a completed action. Save confirmed, invite sent, export ready.
note (prototype only) — Annotation for DX about scope, constraints, or deferred features. Amber surface so DX identifies these at a glance. Never in production UI.
When to use each kind
KindUse whenDon't use when
info Persistent context the user needs on-page — POS constraint, read-only scope, data lag warning Content is a prototype note (→ note). Content is a confirmation (→ success).
warn Attention needed before proceeding — billing degraded, destructive confirmation, time-sensitive Flagging prototype-only scope limits for DX (→ note).
error An error occurred and action is needed — form failed, sync broken, auth rejected Something might go wrong (→ warn). Toast is better for transient errors.
success Action completed, user needs persistent confirmation on the page Transient confirmations (→ Toast). Neutral entity status (→ StatusPill).
note Prototype only. Scope constraints, deferred features, DX implementation notes Never in production UI. If real users see it, use info or warn.
Specs
PropertyValue
Padding12px 16px
Border radiusvar(--r-sm) = 8px
Icon22×22px circle · filled with fg color · cream text · always present
Body font500 14px/1.5 Inter
Optional actionRight-aligned · same fg color · 600 weight · single CTA only — never two actions in one alert
PlacementBelow PageHead, above FilterBar / content. Never inside a table row or drawer header.
StackingMax 2 per page. If warn + info both present, warn goes first.
✓ Do
  • Use note for any text starting with "DX:" or describing prototype scope
  • Place alert directly below PageHead as first child in page body
  • Use success for persistent confirmations; Toast for transient ones
  • Keep body text to 1–2 lines — link out for more detail
✗ Don't
  • Don't use info or warn for prototype notes — DX can't tell the difference
  • Don't stack more than 2 alerts on a single page
  • Don't place an InlineAlert inside a DetailDrawer header — put it in the drawer body
  • Don't use error for "might fail" — that's warn
02

Top Nav Bar

Shell component · Rendered in: index.html App shell · CSS class: .topbar

Live rendering
GA · Global
G
Country Manager (Philippines)
CM · PH
A
Outlet Manager
OM · BGC
M
Anatomy
ZoneContentsNotes
Left Hamburger menu icon only (Ic.menu(28)) Opens the side nav drawer on click. No logo here — logo lives in the drawer.
Right Notification bell → Scope chip Always in this order, left to right. No other elements.
Scope chip ROLE · SCOPE label + avatar circle Avatar: always --forest background, --lime text, first initial uppercase. Use UserMenuChip component — never inline.
Scope label format GA · Global / CM · PH / AM · {franchisee} / OM · {outlet} Role abbreviation, then scope. Abbreviate long names to ~8 chars.
Specs
PropertyValue
Padding20px 30px 0 30px (top-heavy; page content starts below)
Backgroundvar(--cream)
Icon buttons36×36px · radius var(--r-sm) · color var(--forest) · hover: rgba(0,106,86,.08) fill
Scope chipRadius var(--r-pill) · background var(--mint-soft) · padding 4px 6px 4px 10px
Avatar28×28px circle · background var(--forest) · color var(--lime) · 700 13px Inter
Scope label700 12px Inter · color var(--forest) · letter-spacing .02em
✓ Do
  • Always use --forest for the avatar background (not --orange)
  • Always show ROLE · SCOPE in the chip — never just the role
  • Use UserMenuChip component for the scope chip, not inline JSX
  • Keep right zone to: bell + scope chip only
✗ Don't
  • Don't use --orange for the avatar — that's a known bug, not a variant
  • Don't add extra icon buttons (search, help, etc.) without design approval
  • Don't put the Sixhands logo in the topbar — it lives in the side nav drawer
  • Don't vary the topbar per role — it's identical except for the scope chip text
04

Table List

Component: Table · File: shared.jsx · Above: BulkBar (when selected), FilterBar · Below: TableFooter

Live rendering — Franchisees table
Franchisee Country Outlets Status
Thai Hots Group 🇵🇭 Philippines 12 Active
Manila Fresh Co. 🇵🇭 Philippines 4 Pending
Green Bowl PH 🇵🇭 Philippines 7 Active
No results match your filter
Multi-select — BulkBar placement

BulkBar appears above the table, below FilterBar — animated in when ≥1 row is selected. Disappears when selection is cleared.

3 selected
FranchiseeCountryStatus
Thai Hots Group🇵🇭 Philippines Active
Manila Fresh Co.🇵🇭 Philippines Pending
Green Bowl PH🇵🇭 Philippines Active
ElementDetail
PlacementAbove the Table, immediately below FilterBar. Never inside the table or in the page header.
TriggerRenders when selectedCount ≥ 1; hidden (returns null) when 0. Animate in with bulkIn keyframe.
Left: count + deselect"N selected" label + "Deselect all" underline link in --lime.
Right: actionsPass <Btn> components via actions prop. Danger actions leftmost; primary/confirm rightmost.
Component<BulkBar selectedCount={n} onDeselect={fn} actions={…}/> — file: shared.jsx
Sortable columns — interactive demo Click a column header to sort
Franchisee Country Outlets Status
Rules
RuleDetail
Primary column Every table has exactly one primary column — the entity name/identifier. Style: 700 14px/1.2 Inter, color var(--forest). Always the leftmost data column. Use render: r => <span style=…> — never global inline styles repeated per-table.
Row click Clicking any row opens the DetailDrawer for that record. The row must have cursor: pointer and a hover background of rgba(0,106,86,.03). Never navigate away on row click — that's what the drawer is for. Exception (Network modules — Countries, Outlets, Franchisees, Areas, Users): their detail is a full page (03-ui convention 2), so the row click navigates to the full-page Detail instead of a drawer. For Users, OM (who manages no one) has no list — the nav lands directly on their own full-page detail titled "Me".
Row actions Reveal on hover only (opacity 0 → 1). Max 2 icon buttons per row. Use iconbtn class for the 28×28px icon buttons. Place in rightmost column.
Numeric columns Use Mono component (or font-feature-settings: "tnum") for IDs, counts, amounts. Right-align currency columns only.
Empty state Filtered zero results: inline empty row with muted italic message. No records at all: use EmptyState component below the table header.
Sortable columns Add sortable: true (and optionally sortKey) to a column definition. The Table component manages sort state internally. Active column highlights in --lime; idle sort icon is dimmed (opacity .45). Never implement custom sort outside the component.
FilterBar + Search Always present above every table. Include a SearchBar as the first element on list / table pages by passing search + onSearch props. Dashboards and scope-only filter strips (e.g. country + date-range) may omit search — when onSearch is not supplied, the search slot is hidden automatically. Additional Select filters appear after search. Use FilterBar component — never custom filter UI.
TableFooter Use TableFooter below every paginated table. 3-col layout: left = "Show N per page" dropdown (default 20); centre = page numbers; right = Prev + Next grouped. Never split Prev and Next to opposite ends. Legacy Pagination/Showing are kept for backward compat only.
Multi-select When a table supports row selection, render BulkBar above the table (below FilterBar) when selectedCount ≥ 1. Never embed bulk actions inside the table footer or page header.
Specs
PropertyValue
Header font700 11px/1 Inter · uppercase · letter-spacing .08em · color var(--mint)
Sort icon — idleDual-arrow Ic.sort(14) · opacity .45 · cursor: pointer on th
Sort icon — active ascFilled up-arrow Ic.sortAsc(14) · color var(--lime)
Sort icon — active descFilled down-arrow Ic.sortDesc(14) · color var(--lime)
Header border1.5px solid var(--line)
Row border1px solid var(--line)
Cell padding13px 16px 13px 0 (no left padding on first col)
Row hover bgrgba(0,106,86,.03)
Primary col style700 14px/1.2 Inter · color var(--forest)
Secondary col style400 13px/1.4 Inter · color var(--ink-2)
Numeric col style600 12px/1 monospace · font-feature-settings "tnum"
✓ Do
  • Always define the primary column style in the columns array's render function — not repeated inline across screens
  • Use FilterBar above every filterable table
  • Open DetailDrawer on row click — never navigate away
  • Show both Showing count and Pagination together
✗ Don't
  • Don't repeat font: "700 14px/1.2 var(--font-ui)", color: "var(--forest)" inline in every table's render — extract to a shared pattern
  • Don't show row actions at full opacity — they reveal on hover only
  • Don't add a "View" or "Details" text link in the row — row click IS the view action
  • Don't omit the SearchBar from FilterBar — search is mandatory on every filterable table
  • Don't implement sort outside the Table component — use sortable: true + optional sortKey in the column definition
04b

FilterBar

Component: FilterBar · File: shared-blocks.jsx · v2.2 Props: search, onSearch, advancedFilters, advancedValues, onAdvancedChange, onAdvancedReset, onReset, rightActions. primaryFilter / primaryValue / onPrimaryChange still accepted but auto-merged into the drawer (no longer inline). Legacy v2.0 props (filters / moreFilters) still supported during migration.

v2.2 — common filters inline + icon-only Filters trigger (rightmost)

Review 8 follow-up: the bar carries the filter glyph, search, 1–2 commonly-used inline filter Select(s) (typically Status), grow-spacer, Reset, and an icon-only Filters button positioned rightmost. The button expands to icon + numeric badge when ≥1 filter is active. The inline filter(s) are ALSO surfaced inside the drawer (state shared) so users can edit from either entry point. All other dimensions (country, outlet, date, role…) live only inside the drawer. Single row, no wrap — the bar fits within one row at tablet width.

FilterDrawer open state · 420 px · reuses .sub-drawer.filter-drawer + .sub-scrim
Advanced filters
Refine the list with additional dimensions.
BGC
Fort
Makati
GeoScopeFilter · inside FilterDrawer · GA view = country + area + outlet (CM = area+outlet · AM = outlet · OM = none)
Country
🇵🇭 Philippines
🇸🇬 Singapore
Area
Metro Manila
Cebu · out of scope
Outlet
BGC
Makati
Fort
Live rendering — Franchisees filter bar
More filters modal (opens centred on page)
More filters
Anatomy
SlotRequiredDetail
Filter iconAlwaysLucide ListFilter 18px · var(--forest) · decorative, no interaction
SearchAlwaysInput with leftIcon={Ic.search(16)} (icon INSIDE the input chrome, not floating beside it). Props: search + onSearch + searchPlaceholder. Width: flex: 1 1 180px; max-width: 260px. Placeholder must be contextual: "Search franchisees…", "Search outlets by name…" — never generic "Search…"
Select filtersAs neededOne Select per primary dimension (Status, Country, etc.). minWidth: 140px. Render after search. Keep to 2–3 max — move extras to More.
More buttonWhen >3 filter dimensionsLucide SlidersHorizontal 14px + "More" label. Shows active count badge when extended filters are set. Opens FilterModal centred on page.
ResetWhen filters activeText button, var(--ink-2), 13px 700. Resets ALL filters including search and extended. Pass onReset prop.
rightActionsOptionalAction buttons (e.g., Export). Separated by a 1px divider. Render rightmost.
Specs
PropertyValue
ContainerWhite card · padding 14px 16px · position: sticky; top: 0; z-index: 5 · flexbox wrap
Height46px for all inputs/selects (matches DS Input component height)
Gap12px between all elements
Search widthflex: 1 1 180px; max-width: 260px — expands to fill space, cap at 260px
Select widthmin-width: 140px; flex: 0 1 auto
Sticky behaviourSticks to top of scroll area as user scrolls through the table below
Rules
RuleDetail
Search is mandatoryEvery FilterBar must include a search input. Pass search + onSearch + searchPlaceholder. Placeholder must be contextual — "Search franchisees…", never generic "Search…".
Search icon placementSearch icon (Lucide Search 16px, var(--moss)) lives INSIDE the input chrome via Input leftIcon prop — same look as other filter fields. Never float the icon outside the input box.
Order of elementsFilter icon → Search → Select filters (by specificity, broadest first) → More → Reset → rightActions. Never reorder.
Primary vs. extended filtersShow 2–3 primary filter dimensions inline. When more dimensions exist, move them to the More button → FilterModal. Never add more than 3 inline selects — the bar becomes unreadable on tablet.
More filters modalUse FilterModal component (field-per-row layout, Apply + Reset footer). Extended filters: date fields, numeric ranges, multi-select dimensions. Show active count badge on More button.
Never custom filter UIAlways use FilterBar + FilterModal. Never build a custom filter strip inline.
StickyFilterBar must always be sticky — stays visible as users scroll through long tables.
Reset scopeReset clears ALL filters including search and all extended filters. Never reset only some.
✓ Do
  • Always include SearchBar as the first filter element
  • Keep FilterBar sticky — it travels with the user as they scroll
  • Use Select for discrete dimensions (Status, Country) — never checkboxes
  • Show Reset only when at least one filter is active
✗ Don't
  • Don't omit search — even a table with only one filter dimension still needs SearchBar
  • Don't use custom filter UI — always use the FilterBar component
  • Don't put FilterBar inside a card — it wraps tables at page level, not inside a data card
04c

DateTimeFilter

Component: DateTimeFilter · File: shared-blocks.jsx (C-16b) · Props: value { mode, date, range, time }, onChange, country, todOptions. Surfaced both inline on the bar (FilterBar's optional dateTimeFilter prop, after the primaryFilter select, before the grow-spacer) and inside the advanced FilterDrawer (advancedFilters entry with kind:"datetime") — both edit the same caller state, so a change in either place updates the other.

03-ui convention 7 — inline date + time filter (mode + time)

A compact rounded-rect trigger field (var(--r) radius, 46px tall, 1.5px --mint border — matched to the inline .input-icon Select/search so it sits flush in the bar; not a pill) labelled "Today · Full day", "12 May · 09:00–14:00", "1–7 Jun" opens a popover with a SegToggle for Date (Single dateDate range — single uses one DateField defaulting to Today; range uses the from→to DateRangePicker mechanism). The Time section (Full day default ⇄ Time range — two optional <input type="time">; an empty bound = open-ended) renders only in single-date mode; switching to date-range hides the Time section and resets time to Full day so a stale time window can't keep filtering invisibly. The active country's time_of_day_range presets from Custom Options render as quick-fill chips that populate start+end (manual edits stay independently optional). Emits the default shape (Today · Full day) so callers can distinguish "default" from an explicit pick. Filters operate in the record's local timezone. Built from canonical SegToggle / DateField / MenuPopover / MenuItem — never hand-roll.

Date
Time
✓ Do
  • Resolve todOptions in the caller — CUSTOM_OPTIONS where option_type==='time_of_day_range' and scope===country || 'ALL'
  • Use SegToggle for both the date-mode and time-mode switches
  • Allow open-ended time bounds — start-only, end-only, or both
  • Emit the default shape (Today · Full day) so default is distinguishable
  • Surface the same state inline AND in the drawer (kind:"datetime") when both entry points are wanted — share one caller state
✗ Don't
  • Don't hand-roll the popover or the toggles — use MenuPopover / MenuItem / SegToggle
  • Don't make the trigger a pill — it's a rounded-rect field (var(--r)) flush with the inline Select
  • Don't keep the Time section visible (or a set time window live) in date-range mode
  • Don't hardcode time-of-day presets — they come from Custom Options per active country
  • Don't fill the quick-fill chips with --lime on the cream surface — forest/mint only
05

Detail Panel

Component: DetailDrawer · File: shared-blocks.jsx · Props: open, onClose, title, subtitle?, tabs?, actions?, children

Live rendering — Detail panel open state
Franchisees
FranchiseeOutletsStatus
Thai Hots Group12Active
Manila Fresh Co.4Pending
Manila Fresh Co.
franch_ph_0042
Status
Pending
Country
🇵🇭 Philippines
Contact
Ana Reyes · ana@manilafresh.ph
Outlets
4 active · 1 pending
Rules
RuleDetail
Width 700px canonical. CSS class .sub-drawer sets width: 700px — this wins over the component's width prop default of 560px. Use the component prop only to override to a narrower width for simple read-only panels.
Trigger Opened by row click in a table. Never opened by a button labeled "View" — the row IS the trigger.
Sections vs. tabs Use Tabs when there are 3+ distinct content groups (e.g., Overview · Outlets · Audit). Use plain sections (with FormSection or KeyValueGrid) when content is a single coherent group.
Header Title: entity name. Subtitle: ID or secondary identifier (e.g., franch_ph_0042). Close button top-right — always present. No status pill in the header — put it in the body.
Action buttons Bottom of drawer, right-aligned. Max 2 actions. Primary rightmost. Never a destructive action without a confirm step (ConfirmModal).
Scrim Always shows behind the open drawer via .sub-scrim.open. Clicking the scrim closes the drawer (onClose). ESC key also closes — handled by the component automatically.
Inline alert in drawer Place InlineAlert in the drawer body, below the header — never in the title or subtitle props.
Specs
PropertyValue
Width700px (CSS canonical) · override via width prop for narrow panels only
Backgroundvar(--cream)
Inner padding30px all sides (.sub-inner)
Gap between sections24px column gap
Title font700 22px/1.2 Nohemi · color var(--forest)
Subtitle font400 13px/1 Inter · color var(--ink-2)
Shadow-8px 0 40px rgba(0,0,0,.22)
Slide animationtransform: translateX(100%) → translateX(0) · 350ms cubic-bezier(.2,.8,.2,1)
Focus trapBuilt into component — Escape closes, Tab cycles within drawer
✓ Do
  • Use the CSS width (700px) as canonical — don't pass a width prop unless narrowing for a simple panel
  • Use Tabs for 3+ content groups; FormSection/KeyValueGrid for single-group content
  • Place action buttons at the bottom right, primary rightmost
  • Show a ConfirmModal before any destructive action
  • Editable Ops entities (e.g. Customers) use ONE drawer with a mode state — read (KeyValueGrid + an Edit button) · edit (canonical Field/Input/Select/DateField inline, Cancel+Save) · create (same form blank, opened by a New {Entity} PageHead button). Derived fields stay read-only. prototype-standards.md §35.
  • Read and edit render the same sections in the same order (RBAC may hide a section, never reorder): Identity first (carries the entity's status StatusPill) · Approval second for approval-workflow modules (Rewards/Promotions/Partnerships) — the approval_status pill plus submitted_by · approved_by · approved_at — GA changes approval here in edit mode · then the rest · derived sections (Activity) last. A datetime property is _fmtDateTime in read and <Input type="datetime-local"> in edit (same granularity). 03-ui convention 14.
✗ Don't
  • Don't reorder sections between read and edit, bury Status/Approval below the fold, or render a field date-only in read but datetime in edit — and never string-slice a timestamp or define a page-local _fmtDateTime (§37 / CHECK 25)
  • Don't open a drawer from a "View" button — row click is the trigger
  • Don't put a status pill or InlineAlert in the drawer header props — both go in the body
  • Don't place form submit buttons in the PageHead actions when the form is inside the drawer
  • Don't build a separate full-page edit route for an Ops entity — Ops edits in-drawer (§35); full-page edit is the Network-entity pattern
  • Don't nest drawers — one drawer at a time
06

Dashboard

Used on: GADashboard, CMDashboard, AMDashboard, OMDashboard · Sub-components: StatCard, ChartCard · File: shared-blocks.jsx

Every dashboard tier uses the same two building blocks — StatCard for KPI tiles and ChartCard for trend/mix charts — scoped to the role's data visibility. The layout is always: PageHead → InlineAlert (if needed) → StatCard grid → ChartCard(s). Never put charts above stat cards.

06a

StatCard

Component: StatCard · Props: label, value, delta?, deltaDir?, sub?, deltaBasis?, icon?, onClick?

All variants — live rendering
Active outlets
24
GMV · last 30d
₱ 1.82M
AOV
₱ 312
With redemptions
183
GMV · last 7d
₱ 0.42M
8-tile grid (OM outlet dashboard)
Orders
47
GMV
₱ 14.6K
AOV
₱ 311
Avg prep time
8 min
Refunded
4
Voided
1
With redemptions
7
Staff on shift
3
Anatomy
SlotRequiredDetail
labelYesShort metric name. 700 11px Inter · uppercase · var(--mint). Use sentence case abbrevations: "GMV · last 30d", "Orders today".
valueYesFormatted string. 700 28px Nohemi · var(--forest). Include currency symbol, unit, or count in the string. Truncates with ellipsis if too long.
delta + deltaDirNoPercentage change vs. prior period. + --forest for up; + --berry for down. Omit if no comparison period is meaningful.
subNoOne-line metric definition or context. 11px Inter · var(--moss). Always include for non-obvious metrics (AOV, redemption %).
onClickNoMakes the card interactive (drill-down). Shows pointer cursor. Only used on GA dashboard for country drill-down.
Specs
PropertyValue
ContainerWhite card · border 1.5px var(--line) · radius var(--r-lg) · padding 18px · flex column · gap 8px
Label font700 11px/1 Inter · uppercase · letter-spacing .1em · color var(--mint)
Value font700 28px/1.1 Nohemi · color var(--forest) · letter-spacing -.01em
Delta font700 13px/1 Inter · up → var(--forest) · down → var(--berry)
Sub font400 11px/1.3 Inter · color var(--moss)
Grid layoutrepeat(4, minmax(0,1fr)) on GA/CM/AM (fixed 4) · repeat(auto-fill, minmax(160px,1fr)) on OM (8-tile wrapping)
✓ Do
  • Always pass a formatted string to value — include currency symbol, "%" or unit
  • Include sub for any metric whose name doesn't self-explain (AOV, redemption %)
  • Use deltaDir="down" + red color only for negative trends — don't make all deltas one color
  • Use repeat(auto-fill, minmax(160px,1fr)) for OM (8+ cards) to allow natural wrap
✗ Don't
  • Don't put raw numbers in value without formatting — "1820000" not "₱ 1.82M"
  • Don't use onClick unless drilling down to a scoped view — it implies interactivity
  • Don't omit delta just because it's positive — show the trend, good or bad
  • Don't place StatCards below a ChartCard — always stat grid first, charts below
06b

ChartCard

Component: ChartCard · Props: title, subtitle?, kind, data?, height?, actions? · Requires: Chart.js (loaded via CDN in shell)

Three kinds — live rendering Chart.js · hover for tooltips
kind="line" — orders trend
Orders — last 30 days
Philippines
kind="bar" — top SKUs
Top SKUs
By order count · last 30d
kind="donut" — country mix
Country mix
Share of GMV · last 30d
Anatomy
SlotRequiredDetail
titleYesChart name. 700 16px Nohemi · var(--forest). Include time range: "Orders — last 30 days".
subtitleNoScope context: country name, filter label, date range. 400 12px Inter · var(--ink-2).
kindYes"line" for time-series trends · "bar" for ranked comparisons (SKUs, outlets) · "donut" for part-of-whole (country mix, channel split).
dataNoLine/bar: number array. Donut: [{"{"}label, value, color{"}"}] array. Falls back to demo data if omitted.
heightNoCanvas height in px. Default 180. Use 200 for the primary trend chart; 180 for secondary charts side-by-side.
actionsNoRight-aligned slot for a period picker or small Btn. Renders top-right of the card header.
Kind selection guide
When to useKindExample
Metric over timelineOrders per day (last 30d), GMV trend (weekly)
Ranked list comparisonbarTop SKUs by order count, top outlets by GMV
Part-of-whole compositiondonutCountry GMV share, app vs. kiosk split
Specs
PropertyValue
ContainerWhite card · border 1.5px var(--line) · radius var(--r-lg) · padding 20px · flex column · gap 12px
Title font700 16px/1.2 Nohemi · color var(--forest)
Subtitle font400 12px/1 Inter · color var(--ink-2)
Line colorBorder #006A56 (--forest) · fill gradient rgba(179,210,204,.35) → transparent
Bar color#CEF580 (--lime) · border-radius 4px
Donut colorsPrimary → --forest · Secondary → --lime · Tertiary → --orange · Quaternary → --mint
Axis labels11px Inter · color var(--ink-2) · no axis borders, grid lines only
TooltipManaged by Chart.js defaults — no custom tooltip component needed
Height (primary chart)200px — single chart spanning full width
Height (secondary charts)180px — two charts side-by-side in a 2-col grid
✓ Do
  • Place one full-width line chart first (primary trend), then secondary charts side-by-side below
  • Include the time range in the chart title ("Orders — last 30 days")
  • Use subtitle to set scope context (country, outlet, "All countries")
  • Pass actions for a period picker when the chart is filterable
✗ Don't
  • Don't use donut for time-series — use line
  • Don't place ChartCards above StatCards — stat grid always comes first
  • Don't pass raw Chart.js config directly — always use the kind prop abstraction
  • Don't omit subtitle on multi-country dashboards — scope context is mandatory
Extended kinds dual-axis · ranked-list · progress · geo-map / heatmap · matrix-heatmap
kind="ranked-list" — outlet health ranking (caller slices top-N / bottom-N)
Top 3 outlets — GMV
last 30d
1 BGC ₱ 612K ▲ +8%
2 Makati ₱ 452K ▼ −3%
3 Fort ₱ 355K
kind="progress" — GMV MTD vs target (reuses ProgressBar)
Country GMV — MTD vs target
June
Philippines
9.2M / 10M
Singapore
6.4M / 10M
kind="geo-map" / "heatmap" — GeoMap (Leaflet + OpenStreetMap)
Cross-country GPS heatmap
customer activity · SG + PH
Leaflet/OSM map renders here at runtime
(markers = density · heat = L.heatLayer)

GeoMap requires the Leaflet + leaflet.heat CDN (loaded in the prototype shell). Static replica shown as a placeholder — live map needs the runtime.

kind="matrix-heatmap" — outlet × hour grid (cell shade = --forest ramp; dark = high)
Hourly heatmap — all outlets
transactions by hour · this week
BGC High Street
SM Megamall
9a 11a 12p 1p 2p 6p 7p

Cell intensity = value ÷ row-set max, mapped to a --forest opacity ramp via color-mix on --cream-2 — never --lime (cells sit on a white card). Classes .matrix-heat* live in styles.css and auto-propagate.

dual-axis is a Chart.js canvas kind (two y-axes) — see shared-blocks.jsx ChartCard; not replicated statically here.

06c

Avatar & File Upload

Components: Avatar, FileUpload · File: shared.jsx

Avatar renders a circular image (object-fit cover) or initials derived from a label on a forest disc. FileUpload composes an Avatar preview with Upload / Remove buttons for profile-image fields (B-60).

Avatar — sizes & fallback
JH TT NN sample

size: sm (28) · md (40, default) · lg (72). Initials = first letter of up to two words from label; image used when src is set.

FileUpload — profile image
TT

When value is set, the preview shows the image and a ghost "Remove" button appears beside "Replace". Prototype-only: Upload emits a mock asset path (no real file IO).

TemplateFileField — download template + upload custom
Idle — no file uploaded
Uploaded — filename chip with remove
sku-import-custom.xlsx

Distinct from FileUpload (image-only). Use for CSV/XLSX/JSON imports where a canonical template should be downloadable first. Props: label, templateHref, value (filename|null), onChange(filename|null). Prototype: Upload emits a mock filename.

PolicyEditorModal — legal policy editor
Refund policy — Philippines
Markdown-style formatting supported in production.

Wide modal (660 px) for editing legal policy content as rich text. Reuses .sub-scrim.open overlay pattern. The template prop preloads content when value is empty. Footer: secondary Cancel + primary Save. Component: PolicyEditorModal · File: shared-blocks.jsx.

06d

Switch Role & Act-as

Components: SwitchRoleModal, ActAsBanner · File: shared-blocks.jsx · GA-only (B-61 · 03-ui §2)

A Global Admin can "act as" a lower role to view the console with that role's narrowed scope. SwitchRoleModal is a cascading scope picker (Role → Country → Area for AM/OM → Outlet for OM); ActAsBanner persists at the top of the shell while act-as is active.

ActAsBanner
Acting as Country Mgr PH · Metro Manila

Amber warn-styled banner so the operator never forgets the impersonated context. The RoleBadge reflects the active role; scope label = country · area · outlet as applicable.

SwitchRoleModal — cascading scope
RoleRequired scope
Country ManagerCountry
Area ManagerCountry → Area
Outlet ManagerCountry → Area → Outlet

"Act as" stays disabled until the required scope for the chosen role is fully selected. Emits onActAs({ role, scope }).

07

Left Nav

Component: Drawer · File: styles.css (.drawer.*) · Nav data: components/nav.js (NAV_MODEL) · Authoritative IA: docs/specs/v2/03-ui.md §1 — sections (Network/Ops/Config/Account) + indentation (.nav-child); broad per-role visibility (seeing a menu ≠ permission).

Live rendering — GA role (full nav)
Sixhands
ADMIN · GA
Logo
assets/logo-light.svg · height 22px on forest bg · fill="--cream". Role subtitle below: --mint 60% opacity · 10px Inter 600 · letter-spacing .24em · uppercase.
Section label
.nav-section · Network / Ops / Configuration / Account (per 03-ui §1) · 10px Inter 600 · uppercase · letter-spacing .2em · --mint 45% opacity. The Dashboard sits above the first section (no header).
Nav item
.nav-item · 14px Nohemi 600 · color --mint (inactive) · padding 10px 14px · border-radius 10px · icon 16×16px 1.8px stroke.
Child item (↳)
.nav-item.nav-child · indented sub-module (padding-left 32px · 13px · icon .75 opacity). Per 03-ui §1: Customers↳Messenger·Loyalty·Wallet · Products↳Orders·Refunds · Partnerships↳Promotions · Rewards↳Redemptions · Gifts↳Purchases.
Active
.nav-item.active · color --lime · bg rgba(196,217,102,.18).
Hover
.nav-item:hover · color --cream · bg rgba(255,255,255,.05).
Divider
.nav-divider · 1px solid rgba(255,255,255,.08) · margin 12px 14px.
Logout
.nav-logout · same shape as nav-item · color rgba(255,255,255,.45) — visually de-emphasised vs section items.
Specs
ElementToken / Value
Drawer bgvar(--forest) · width 351px (prototype) · padding 28px 20px
Logoassets/logo-light.svg (cream fill) on forest bg · assets/logo.svg (forest fill) on light bg · height 22px · width auto
Role subtitleNohemi 600 · 11px · letter-spacing .24em · var(--mint) 60% opacity · format: "ADMIN · ROLE"
Section labelInter 600 · 10px · letter-spacing .2em · uppercase · var(--mint) 45% opacity · padding-top 20px (8px for first)
Nav item — defaultNohemi 600 · 14px · var(--mint) · padding 10px 14px · border-radius 10px
Nav item — activecolor var(--lime) · bg rgba(196,217,102,.18)
Nav item — hovercolor var(--cream) · bg rgba(255,255,255,.05)
Nav icon16×16px · stroke-width 1.8 · Lucide · colour inherits from nav-item
Divider1px · rgba(255,255,255,.08) · margin 12px 14px
LogoutSame shape as nav-item · color rgba(255,255,255,.45) · no active state
Rules
RuleDetail
Nav data sourceAll nav items, groups, and roles come from nav.js NAV_GROUPS. Never hardcode nav labels or icons in the shell.
Role scopingEach group has a roles array — only show groups matching the active role. Use navForRole(role) helper.
Section labelsUse .nav-section for group headers ("GLOBAL", "COUNTRY", "ACCOUNT"). Never use a full divider line as a section header.
DividersUse .nav-divider only between the main nav group and the Account group, and above Logout. Never between individual items.
Active stateExactly one .nav-item.active at a time. Driven by current route — never by click state alone.
LogoutUse .nav-logout (not .nav-item) — it must read as lower-priority than section items. Always last, after a divider.
Logo "hands" fontMust be Damion script (var(--font-script)). Never Nohemi italic — the curve of "h" in Damion is the brand mark.
✓ Do
  • Use .nav-section labels ("GLOBAL", "ACCOUNT") to group items — keeps nav scannable at a glance
  • Use logo-light.svg on forest bg · logo.svg on cream/white bg — never recreate the wordmark in type
  • Use .nav-divider sparingly — only before Account section and before Logout
  • Drive active state from the router, not from click state
  • Show role in subtitle ("ADMIN · GA") so users know their permission tier
✗ Don't
  • Don't recreate the wordmark in type (Nohemi/Damion) — always use the SVG asset
  • Don't use logo.svg (forest fill) on forest bg — it's invisible; use logo-light.svg
  • Don't put Logout inside the Account .nav-section group — it must be visually separate
  • Don't hardcode nav items in JSX — always read from NAV_GROUPS
  • Don't add a divider between every section — only use where a clear break is needed
  • Don't use .nav-item.active on Logout — it has no active state
08

Tabs

Component: Tabs · File: shared-blocks.jsx · Props: tabs, active, onChange

Live rendering — Franchisee detail tabs
Tab content renders here — each tab manages its own content panel below the strip.
Specs
PropertyValue
Tab font700 14px/1 Inter
Active colorvar(--forest) · underline 3px solid var(--forest) · marginBottom: -1.5px (overlaps border)
Inactive colorvar(--ink-2) · underline 3px solid transparent
Tab padding10px 14px
Count badge — activebg var(--forest) · text var(--cream) · 700 11px Inter · 20px height · pill radius
Count badge — inactivebg var(--mint-soft) · text var(--forest)
Strip border1.5px solid var(--line) at bottom of the tab container
Component call<Tabs tabs={[{id, label, count?}]} active={activeId} onChange={setActive}/>
Rules
RuleDetail
When to use TabsUse when a page has 3 or more distinct content groups that users switch between (e.g., Overview · Outlets · Users · Payment · Activity · Settings). Never use for 2 options — use a toggle or section header instead.
When NOT to use TabsIf content is a single coherent group, use plain sections with FormSection or KeyValueGrid. Don't use tabs just to reduce scroll length.
Count badgesOnly add a count badge when the number is meaningful and changes (e.g., number of outlets, users). Don't add counts for static or unknown quantities.
Active stateExactly one tab active at a time. Driven by state — never by URL alone without setting active on mount.
PlacementTabs strip sits directly below PageHead (or below InlineAlert if present), before the tab's content area. Never inside a card or drawer header.
Action scoping Page-level actions (apply to the whole entity, regardless of active tab) → PageHead.actions.
Tab-specific actions (only relevant when this tab is active) → inside the tab content panel, as a header row above the tab's FilterBar or content.

Example — Franchisee detail:
· "Edit franchisee" + "Suspend" → PageHead.actions (always visible)
· "Add Outlet" → inside the Outlets tab panel
· "Invite User" → inside the Users tab panel

Quick test: if switching to a different tab would make this button irrelevant, it belongs in the tab panel, not PageHead.
Prototype usageCurrently used in: Franchisee detail (GA-030), with tabs: Overview · Outlets · Users · Payment · Activity · Settings. See page-franchisees.jsx (FranchiseeDetail).
✓ Do
  • Use Tabs for 3+ distinct content groups on the same entity
  • Add count badges only when the count is live and meaningful
  • Keep tab labels short (1–2 words) — use nouns, not verbs
  • Put tab-specific actions (Add Outlet, Invite User) inside the tab content panel as a row above the content
  • Put page-level actions (Edit, Suspend) in PageHead.actions — they stay visible across all tabs
✗ Don't
  • Don't use Tabs for 2 options — use a section divider or toggle instead
  • Don't nest Tabs inside tabs — only one level of tabs per page
  • Don't put Tabs inside a DetailDrawer header — use the tabs prop on DetailDrawer instead
  • Don't put tab-specific action buttons in PageHead.actions — they belong inside the tab panel
09

Cross-role consistency

Iron-out rules v2.5 · enforce same data → same UI across GA / CM / AM / OM tiers · canonical source: prototypes/admin/COMPONENTS.md §6 + .claude/rules/prototype-standards.md §19–23

The five iron-out rules
RuleConstraint
S1 · PaginationEvery standalone list page renders <TableFooter> with real page/pageSize state — even single-page demos. Embedded tables (drawers, "Recent Orders" sub-cards) are exempt.
S1 · SortableEvery data column on every <Table> declares sortable: true. The shared Table renders sort glyphs + handles state automatically. Status pill columns and action-icon columns stay non-sortable.
S2 · Add-newPlace a primary New {Entity} button in PageHead.actions for every role × entity with create permission per 02-roles-permissions.md. Use the labelled form, not bare +. OM has no entity-creation buttons.
S3 · StatCard subEvery <StatCard> passes sub= — even simple counts. The slot reserves minHeight: 29 regardless; missing sub leaves visible empty space.
S4 · ContactEmail / phone values render as <value> + .contact-icon-btn (Mail/Phone Lucide icon). Use _emailLink(v, {muted}) / _phoneLink(v, {muted}) in helpers.jsx. Never add separate "Email customer" / "Message" / "Contact" action buttons.
S1c · Date-time displayDisplay, not filter (distinct from the DateTimeFilter, §04c). One canonical format YYYY-MM-DD HH:MM <TZ> (e.g. 2026-06-03 09:14 SGT), byte-identical in table Date/Timestamp columns and detail views. Helpers in helpers.jsx — never re-format inline or define a page-local copy (CHECK 25). Two cases: (a) event instants (approved_at, created_at, redeemed_at — stored UTC) use _fmtDateTime(iso, tz), shifted to the tz frame = the viewer's scope (_scopeTz(scope); GA-global → the HQ default _gaTz()). (b) schedule windows (start_date/end_date — a naive local wall-clock, the same value the datetime-local editor holds) use _fmtLocalDateTime(naive, tz) — same format, no UTC shift (so read = edit). Window tz = the record's single market, or _gaTz() for a multi-market record. _gaTz() = the GA/HQ default tz, GA-set via the hq_timezone scalar Custom Option (default SG). Date-only / time-only column variants: _fmtDate / _fmtTime (no TZ suffix).
S6 · StatPanel (v2.6)Every StatCard cluster lives inside <StatPanel> — filter strip + grid in one card surface. Universal default mode: "range", range: 7D. Mode toggle (Date / Range) always visible. Delta gated on statPanelShowDelta(state); charts hidden when mode === "date". Snapshot variant (no state) for status counts. A delta-bearing StatCard MAY pass deltaBasis={statPanelDeltaBasis(state)} to show the comparison basis on a 2nd sub line with a muted-moss triangle marker (never lime). Helpers statPanelShowDelta / statPanelCaption / statPanelDeltaBasis are the single source of truth — never reimplement.
§25 · DS-first workflowDetect → Check → If missing, extend the DS first → Then consume. Never inline a one-off in a page-*.jsx module or in an HTML replica when a canonical component / class doesn't exist — STOP, ship the DS change first (six required artifacts: shared-blocks.jsx → styles.css → admin-ui-standards.html replica → COMPONENTS.md → prototype-standards.md if new constraint → audit). HTML replicas reuse existing canonical classes (.sc, .sp-*, .tbl-*) — never inline custom styles. Full rule + workflow in prototype-standards.md §25.
§27 · Drift review (compounding loop)When inconsistency is reported (user flag / DS SYNC hook / new audit violation): run the 5-step workflow — Inventory → Five-whys → Categorize (missing-variant / missing-rule / unenforced-rule / ambiguous-canonical / skill-gap / hook-blind-spot) → Propose → Land. Required closing artifacts: DRIFT_LOG entry · audit-script CHECK (unless waived) · rule update if new constraint. Each review compounds the audit script so the same class never recurs. Search DRIFT_LOG.md before adding any new pattern. Full workflow in prototype-standards.md §27.
Contact pattern — inline icon-buttons (S4)

Same pattern in table cells, KeyValueGrid values, and DetailDrawer bodies — one affordance per channel, in one place.

Name Email Phone
Maria Santos maria@foodwave.ph +63 917 411 5580
ElementSpec
Trigger<a href="mailto:…"> or <a href="tel:…">
Class.contact-icon-btn (in styles.css)
Size24×24 round, icon 14px
Default stateicon var(--forest), transparent background
Hoverbackground var(--mint-soft), icon var(--forest-deep)
Spacing6px gap from value
Clicke.stopPropagation() required (so it doesn't open the row's drawer)
DO Inline next to the value
  • Use _emailLink(email) / _phoneLink(phone) helpers in helpers.jsx
  • Pass {muted: true} for table cells, omit for KeyValueGrid items
  • Use the same pattern in tables, drawers, and detail pages — one affordance per channel
DON'T
  • Add separate "Email customer" / "Message customer" / "Contact" buttons below the row or in drawer actions
  • Render email/phone as plain text without the inline icon
  • Use a ghost <Btn> with onClick+window.location.href — use <a href> instead
Date-time display — one canonical format (S1c)

Display, not filter — distinct from the DateTimeFilter (§04c), which slices records. The same timestamp string appears in table Date/Timestamp columns and in detail views, byte-identical, because both call _fmtDateTime(iso, tz) (helpers.jsx). Record-local timezone (outlet → country, convention 7), never the viewer's.

VariantHelperOutput
Date + time (canonical)_fmtDateTime(iso, tz)2026-06-03 09:14 SGT
Date only (column)_fmtDate(iso, tz)2026-06-03 (no TZ suffix)
Time only (column)_fmtTime(iso, tz)09:14 (no TZ suffix)
ElementSpec
FormatYYYY-MM-DD HH:MM <TZ> — 24-hour, zero-padded
TimezoneRecord-local (outlet → country frame), convention 7. SG→SGT(+8) · PH→PHT(+8) · ID→WIB(+7) · TH→ICT(+7); fallback UTC
Table === detailBoth surfaces call the same _fmtDateTime — never re-format inline
ParsingNaive ISO (no Z/offset) treated as UTC; falsy → ; unparseable → raw input
First appliedWallet (SG-only) — passes "SG" in both the table Date column and the detail-drawer Date field
DO Format once, render everywhere
  • Call _fmtDateTime(iso, tz) in both the table cell and the detail view
  • Pass the record's country frame as tz (Wallet → "SG")
  • Use _fmtDate / _fmtTime only when a column needs date- or time-only
DON'T
  • Re-format inline (iso.slice(0,10), per-module _sgtDisplay, _ISO_DATE(ts)) — drift between table and detail
  • Omit the TZ suffix on the canonical date+time form
  • Render in the viewer's browser timezone — always the record's local frame
SectionLabel — canonical inline section header

Uppercase section header (T.label tokens) for the no-tabs dashboard's ~30 section headers. Optional sub (muted helper) + right slot (link / count). Reuses the .sec-label class shared with the React source — no inline repetition.

Network
GMVLast 90 days
StatPanel pattern — filter strip + StatCard grid in one section (S6, v2.6)

Universal default: mode: "range", range: 7D. Mode toggle always rendered. Charts hide in date mode.

GMV
S$ 1.42M
Orders
8,420
AOV
S$ 33.9
App vs Kiosk
58 / 42
ElementSpec
Default stateSTAT_PANEL_DEFAULT_STATE = { mode: "range", range: { preset: "7D" }, date: { preset: "today" } }
Mode toggleSegToggle (.seg-toggle / .seg-btn). Always visible. Active button: forest bg + cream text.
Date mode presetsToday · Yesterday · Pick date (custom <input type="date">). No delta on cards. Charts hidden.
Range mode presets7D · 30D · 90D · Quarter (QTD, ~90d) · Year (YEAR, 365d) · MTD · YTD · Custom (from→to date inputs). Delta shown. Charts visible. A dashboard may seed a per-role initial preset (e.g. GA → Quarter) at the call-site without mutating STAT_PANEL_DEFAULT_STATE (Reset still returns to 7D).
Header iconSliders (Lucide). Distinct from Filter funnel which is reserved for tabular FilterBar.
Snapshot variantOmit state + onChange → renders just the card grid in panel surface (no header). For PaymentStatus, Inbox, FranchiseeDetail.
Delta gatingAlways delta={statPanelShowDelta(state) ? "+5%" : null}. Never hand-write delta unconditionally.
CaptionAppend ${'`${captionBasis}`'} from statPanelCaption(state) for range-bound metrics. Static caption for entity-fixed metrics.
ChartsWrap <ChartCard> in {state.mode === "range" && …}. Single-day data isn't a trend.
DO Wrap every StatCard cluster
  • Use useState({ ...STAT_PANEL_DEFAULT_STATE }) for state
  • Gate delta= on statPanelShowDelta(state)
  • Use scopeSelect prop for country/area selectors in the header
  • Hide trend ChartCards in date mode
DON'T
  • Render bare <div style={{ display: "grid" }}>{statCards}</div> outside a StatPanel
  • Hand-write delta="+5%" unconditionally — breaks date-mode contract
  • Reimplement the date/range branching logic — use the helpers
  • Use the Filter funnel icon — that's reserved for tabular FilterBar
10

IntegrationBlock & ManagerSection

Conventions 10 & 11 · shared-blocks.jsx · cross-entity (Country · Outlet · Area) · spec 03-ui.md Table 2/3

IntegrationBlock — POS + payment-gateway renderer (convention 10)

ONE shared renderer for the Integration tab, consumed by CountryDetail & OutletDetail — never a fork. Provider shows inline beside the StatusPill; DX-configured detail nests below, gated on connected. On Outlet, providers + commercial values are inherited from the country (read via join); only pos_store_id is outlet-owned (editable in create/edit, passed via the posStoreIdField slot).

POS integration
connected 8POS
POS
CurrencyPHP
Taxinclusive 12%
Service chargeOff
Payment gateway
connected Stripe
Supporting currencyPHP
Payment methods
Credit Cards GCash GrabPay GooglePay

Caller-owned surround (not part of the component): the <FormSection> wrapper, the GA-only "Run trial transaction" button, and the go-live InlineAlert. The block renders the two-card grid only.

ManagerSection — entity manager block (convention 11)

Managers are Users (scope field: Country→franchise_id · Outlet→outlet_ids[] · Area→area_id) — a SECOND entry point into user assignment, never a manager_id on the entity. Four modes:

read — empty
No Outlet Manager assigned
Assign a Outlet Manager to enable oversight and escalation.
read — assigned
Maria Santos
maria@foodwave.ph
create — Choose user (search existing, skippable, GA)
Outlet Manager — typeahead: list hidden until you type; matches appear in a scrollable dropdown (≤5 visible). Skip to assign later.
create — Quick add (name + email, no notification, GA)
Outlet Manager — add name and email, or skip and assign later.
No notification will be sent.
edit — select existing eligible user only
Outlet Manager — select an existing eligible user.
Ahmad Rahim — ahmad@sixhands.sg
11

ListEditor

GA-curated editable list section · Source: shared-blocks.jsx · CSS: .list-editor / .le-row / .le-tag

One labelled list of small entries (order concepts, dining modes, custom-option presets). Each row exposes inline editable cells per fields schema (kind ∈ text/number/time/toggle), a hover-only remove icon, and an optional cap that hides Add + shows a “max N” hint when reached.

ListEditor — editable + inherited rows (cap reached)

Inherited rows (country overrides global) render greyed / read-only with an inherited tag + a secondary Override action (§9 — ghost is navigation-only, never for actions) that promotes the row to a country-specific editable row.

Order Concepts
max 5
Name
Min spend
Active
inherited
12

ApprovalCard

Component: ApprovalCard · File: shared-blocks.jsx · Props: status, kind, message, canApprove, canWithdraw, onApprove, onReject(reason), onWithdraw

Three states — live rendering
Approver view (canApprove) — Approve (primary, rightmost) + Reject (secondary → reason modal)
Pending
Submitted by Maria Santos · awaiting your approval.
Submitter view (canWithdraw) — Withdraw (secondary) while still pending
Pending
Submitted for approval · you can withdraw while it is still pending.
Terminal view (neither flag) — status + one-liner only, no buttons
Approved
Approved by Global Admin on 5 Jun 2026.
When and how to use
  • Sits directly under the detail title — inside a DetailDrawer body or at the top of a full-page detail. White card surface (.approval-card), never an InlineAlert.
  • Presentational only. The page computes canApprove / canWithdraw from RBAC and passes them in. The card never reads roles.
  • Approver gets Approve (primary forest, rightmost) + Reject (secondary → internal reason modal; reason required). The submitting AM/CM gets Withdraw while pending.
  • Renders only while pending (status starts with pending). For draft / approved / rejected the card returns null (hidden) — those states read from the StatusPill + the Approval audit section.
  • status + kind forward straight to StatusPill — use the approval-bearing kind for the surface (reward · partner · promo_approval · refund).
  • Applies to Promotions · Rewards · Partnerships · Refunds · Outlet-hours. Not Legal policy (uses PolicyEditorModal).

DeleteSection

Component: DeleteSection · File: shared-blocks.jsx · Gate: _canDelete(role, {status, isReferenced, rbacOk}) (helpers.jsx) · Props: entityLabel, gate, soft, note, confirmBody, onDelete

Delete-safety states (06-status §3)
Deletable (draft / inactive + unreferenced) — Delete button shown
Delete country
Decommissions and hides this record.
Blocked (active or referenced) — reason shown, no button
Delete country
Deactivate first — an active record can't be deleted.
When and how to use
  • The only Delete affordance — Network entities in the detail Settings danger zone, Ops entities in the last section of the edit/create DetailDrawer. Owns its own ConfirmModal.
  • Referenced / in use → blocked (any status); unlink first.
  • draft (never launched, unreferenced) → HARD-delete by the creating tier (rbacOk). active/inactive (has been live, unreferenced) → SOFT-delete, GA only (the special condition). Pass the verdict via _canDelete(...) — never inline the policy.
  • soft prop = status !== "draft" (draft hard · active/inactive soft decommission). Enforced by audit-prototype.sh CHECK 29.