DESIGN
Rechaj Design System Reference
Section titled “Rechaj Design System Reference”INSTRUCTIONS FOR CLAUDE
- Read PATTERNS.md FIRST — it’s the per-platform widget recipe book. Identify the form factor (web / tablet / mobile) and pull the canonical widget classes from that section.
- Read this file (DESIGN.md) second for global tokens, principles, and hard rules.
- Do NOT invent tokens, colors, or components that are not listed here or in PATTERNS.md.
- Do NOT read
components/index.htmlwhole — it’s 2.6 MB. Grep for specific classes (.dlg,.ds-btn-primary,.tab-bar,.sb-item) when you need the visual spec.- When building or modifying a flow that may already exist in Figma, use the Figma MCP to fetch only the specific frame(s) — never the full file.
0. Global Rules (Non-negotiable)
Section titled “0. Global Rules (Non-negotiable)”These rules apply to every screen, flow, and component — no exceptions.
Form-factor scope (READ FIRST)
Section titled “Form-factor scope (READ FIRST)”The hard rules in this document apply by form factor. Before designing any new flow, identify the platform, then apply the rules + widgets for that platform. See PATTERNS.md for the per-platform widget recipe book — read it first whenever you start a new design.
| Form factor | Shell | Width × Height | Class root | When to use |
|---|---|---|---|---|
| Web (desktop) | Atlas .sidebar + .topbar + .main |
min-width: 1440px × variable |
Atlas .sb-item / .tab-bar / .dlg / .ds-* |
Back-office, admin, fleet ops, Identity Admin |
| Tablet | .tablet (portrait) |
834 × 1194 | pm-* / im-* / km-* (per-app) + .rsm-* shared scaffold |
Bench tools (Procurement, Inventory, Production) |
| Mobile | .phone |
390 × 844 | rsm-* shared scaffold + per-app bo-* / br-* / ka-* / etc. |
Field tools (RSM, Team Lead, Retrofit Tech, Onboarding, BMS Tech) |
Cross-form-factor invariants (apply everywhere):
- Brand green
#01E46F, dark text on brand-green CTAs (NEVER white-on-green for utility buttons) - Brand mark = exact two-tone SVG, immutable
- Card shadow = exact token
2.083px 2.777px 15.967px 0.69px rgba(193,197,210,0.0266), never a border - 4px spacing grid
- No emojis
- Headings semibold 600. Display weight 700 for KPI digits / wallet amounts / display titles only
- Badges 12px / 500 / 6px radius / NO uppercase, letter-spacing
-0.01em(.ds-badgecanonical) - Toast =
.alert-toasttop-center pill (success / info / warning / error / gray variants), auto-dismiss 4s
Form-factor-specific defaults (apply automatically based on platform):
| Pattern | Web | Tablet | Mobile |
|---|---|---|---|
| Confirmation overlay | Centered .dlg modal (491px) |
Bottom sheet | Bottom sheet |
| Primary navigation | Sidebar (.sidebar + collapse) |
Bottom tabs (.rsm-nav) |
Bottom tabs (.rsm-nav) |
| Detail-screen back nav | Topbar breadcrumb | .rsm-page-head + back chevron |
.rsm-page-head + back chevron (NO bottom nav on detail screens) |
| Settings | .settings-layout (200px nav + panel) |
Tabs / sections | .rsm-profile-section rows |
| Status indicator on cards | Small .ds-badge (subtle variant) |
Tinted footer strip | Tinted footer strip |
| Photo evidence capture | .id-lightbox (fullscreen dark viewer) |
.ka-camera-shell (fullscreen) |
.ka-camera-shell (fullscreen) |
The remaining rules in this section apply to all form factors unless explicitly scoped.
Official Rechaj logo — exact SVG, never substitute (HARD RULE)
Section titled “Official Rechaj logo — exact SVG, never substitute (HARD RULE)”Always use this exact SVG for the Rechaj brand mark. Never use a placeholder letter, a colored square, an emoji, or a re-drawn version. This is the single source of truth.
<svg viewBox="0 0 57 57" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M39.6964 8.40009H33.2341C32.1322 8.40009 31.2388 9.29346 31.2388 10.3955V26.0808H40.8856L19.1794 52.4274H35.6029C36.8217 52.4274 37.9736 51.8704 38.7307 50.9152L52.8618 33.0847C53.3368 32.4853 53.4275 31.6671 53.0951 30.9783L43.2908 10.6568C42.6251 9.277 41.2284 8.40009 39.6964 8.40009Z" fill="#0F1D0F"/> <path d="M17.597 48.0272H24.0593C25.1612 48.0272 26.0546 47.1339 26.0546 46.0318V30.3465H16.4077L38.1139 4L21.6905 4C20.4717 4 19.3198 4.55689 18.5627 5.51204L4.43159 23.3426C3.95655 23.9419 3.86591 24.7602 4.19825 25.4491L14.0026 45.7705C14.6683 47.1502 16.065 48.0272 17.597 48.0272Z" fill="#01E46F"/></svg>- Two-tone bolt mark on a 57×57 viewBox: dark bolt
#0F1D0F+ bright bolt#01E46F. - Wrap in a fixed-size container — typical sizes: 22–28px in app bars / topbars, 32–40px in onboarding cards, 56–80px in splash / empty states.
- Do NOT recolor either path. Do NOT add backgrounds, borders, or gradients to the mark itself. The two-color contrast is part of the mark.
- Pair with a wordmark when needed:
<span>Rechaj</span>or<span>Rechaj Stations</span>— semibold (600),letter-spacing:-0.2px, colorvar(--grey12).
Prototype shell — device-only, no surrounding chrome (HARD RULE)
Section titled “Prototype shell — device-only, no surrounding chrome (HARD RULE)”When asked to build a clickable prototype (mobile, tablet, web), the page must contain ONLY the device mockup on a dark backdrop. Nothing else. No exceptions, do not ask.
The user interacts inside the device — never outside it. This applies to every prototype going forward, matching the swap and support-cases prototypes.
Forbidden outside the device:
- Title labels, subtitles, version stamps, “PRD vX.X” tags, project names
- Bottom legends, keyboard hints (
← →, “Click to navigate”, etc.) - External nav buttons, prev/next arrows, screen pickers, breadcrumbs
- Any explanatory text, captions, or watermarks
- Keyboard shortcut listeners that switch screens (no
ArrowLeft/ArrowRightglobal handlers)
For narrated multi-scenario prototypes, scenario navigation is surfaced by the /flows page, not the prototype itself — the prototype stays a clean device-only shell. The flow’s scenarios array in site/src/data/products.ts declares the captured states; /flows renders them as a side panel and updates the iframe’s ?scenario=<slug> param on click. The prototype reads that param at init to set up the captured state. See patterns/prototype-scenarios.md.
Required:
bodyis a centered flex container with a dark background (#0e0f12/#0e0e0e) and zero outer text — just the device.- All navigation between screens happens inside the device — via in-screen tab strips, bottom nav, CTAs, back buttons, etc.
- File structure:
<body><div class="phone|tablet">…device contents…</div></body>— no wrapper div with surrounding text, no.page-stage, no.proto-label, no.legend.
Mobile shell (390×844): the phone container itself is the only visible element. This is the canonical CSS — copy verbatim. Do not invent variant radii, bezels, or wrappers.
html, body{width:100%;height:100%;background:#0E0F12;display:flex; align-items:center;justify-content:center;overflow:hidden; font-family:-apple-system,'SF Pro Display','Segoe UI',system-ui,sans-serif}.phone{width:390px;height:844px;background:var(--bg);border-radius:32px; overflow:hidden;position:relative;flex-shrink:0; box-shadow:0 50px 130px rgba(0,0,0,0.7), inset 0 0 0 1px rgba(255,255,255,0.06)}Hard rule — mobile shell radius is
32px. Not 40, not 52. The.phonebox-shadowcarries only the deep drop shadow + inner highlight — no chunky bezel layers (0 0 0 3px,0 0 0 7pxrings) — they create a visible curve mismatch at the corners. Reference implementations: rechaj-swap-app/prototypes/swap-station-manager.html, rechaj-agent-app/screens/Rechaj-Agent-Onboarding-Flow.html.
Tablet shell (1024×768 landscape): the tablet device with rounded bezel is the only visible element. Use:
body{background:#0e0f12;display:flex;align-items:center;justify-content:center;padding:0;min-height:100vh}.tablet{width:1024px;height:768px;background:#1a1b1f;border-radius:34px;padding:16px; box-shadow:0 32px 80px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.04) inset}.tablet-screen{width:100%;height:100%;background:var(--bg);border-radius:22px;overflow:hidden; display:flex;flex-direction:column}Web/desktop shell: the canvas itself is the prototype — no enclosing device frame, no surrounding labels.
Auto-applied defaults (do not ask, just apply)
Section titled “Auto-applied defaults (do not ask, just apply)”When a mobile flow needs any of the patterns below, use the documented default automatically. Don’t ask the user, don’t propose alternatives unless explicitly invited.
| Need | Default pattern (auto-apply) |
|---|---|
| Success / confirmation after action | Bottom sheet success modal — see §9 “Success / Confirmation Modal” |
| Single-select pickers (bank, station, country, etc.) | Bottom sheet with .lp-list / .lp-row / .lp-radio |
| Search / lookup overlays (rider, customer, etc.) | Bottom sheet with .sheet-search + .sheet-results |
| Photo / media preview | Bottom sheet with sheet head (filename + size) + .mv-photo |
| Action menus (camera/gallery, share targets) | Bottom sheet with .sh-actions-list |
| Confirmation dialogs (destructive or otherwise) | Bottom sheet, NOT a centered alert |
| App header on a sub-screen | .mhdr pattern with absolute back button + centered title |
| Bottom nav on root screens | .mnav pattern with brand-green top accent for active tab |
| Stat / KPI tiles in mobile lists | Horizontal-scroll .stat-grid (not 1x3 grid) with auto-reset to start |
| Status indication on cards | Tinted footer strip + status badge — NOT a colored left border |
| Status chip palette (workflow state) | 3-color rule: Pending = amber (#FEF3C7/#B45309), In progress = blue (#DBEAFE/#1E40AF), Completed = green (var(--g-3)/var(--g-dark)). Failure outcomes use red (#FEE2E2/#B91C1C). No “Active” status — collapse to “In progress”. Workflow-type chips (Retrofit / Inspection) are separate and keep their own palette. |
| Primary CTA on a screen | .btn (52px, 14px radius, brand green) — .btn.btn-secondary for the secondary |
| Issue type / category icons | 40–44px circle, light-tint background (--g-2/--amber2/etc.), 20px stroke SVG |
| Form fields | .field-group wrapping .field-label + .input (50px/12px-radius) |
| Active tab text color | --g-dark (brand green), never grey-12 |
| Headings, titles, section labels | semibold (600), never bold (700) |
| Detail page entity | .cd-card (head + divider + grid + photo subcard + status banner) + separate .cd-timeline-card for activity |
If a request truly doesn’t fit any of these patterns, follow the lookup order in CLAUDE.md (this file → rechaj-design-system.html → existing prototypes → ask).
Emojis
- NEVER use emojis anywhere in the UI — not in labels, buttons, toasts, badges, status text, headings, empty states, placeholders, or any other UI element.
- This applies to every flow: onboarding, dashboard, settings, modals, mobile, web — everywhere.
Canvas Width
- Web flows: always design at 1440px width. Set
min-width: 1440pxonbody. - Mobile flows: always design at 390px width (matching the swap prototype). Set
width: 390pxon the phone container. - Never use arbitrary widths. Never design a mobile flow at 375px unless explicitly instructed.
Iconography
- ALL icon containers are circular (
border-radius: 50%). Never use rounded squares (8/10/12px radius) for icon backgrounds — only for photo/media thumbnails. - Icons inside cards or rows: 40px circle, 20px stroke SVG, light tinted background (
--g-2,--amber2,--red2,--blue2,--pink2,--grey2) with stroke color matching the family’s [9] step. - Icons inside compact metadata rows (e.g. case detail grid): 32px circle, 16px stroke SVG,
--grey2background. - Stroke icons only — never filled. Stroke-width:
2for most,2.5for checks/dismisses.
Typography weights
- Titles, headings, section labels → semibold (600). Never bold (700) for titles.
- Body text → 400/500.
- Avatars (initials) and status bar time → 600 (small text legibility).
- Numerical values (stat counters, wallet amounts, KPI numbers) → 700/800 (display weight).
<b>and<strong>must render as semibold (600), not browser-default bold (700). Addb,strong{font-weight:600}to base styles in every prototype. Never usefont-weight:700for ordinary inline emphasis (table cell times, embedded bolds in feed items,Decision:prefixes, footer counters, etc.). 700/800 is reserved for the display-weight numerical values listed above.
Stat / KPI cards — never break lines
- Stat card text (
stat-val,stat-lbl,stat-trend) must never wrap to multiple lines. Applywhite-space:nowrapto all three. - When the row of stat cards exceeds the available width, make the row horizontally scrollable — do not shrink cards, reduce font, or wrap text.
- Use this pattern (works on tablet and mobile):
.stat-row{display:flex;gap:10px;overflow-x:auto;scroll-snap-type:x proximity;scroll-behavior:smooth}.stat-row::-webkit-scrollbar{display:none}.stat{flex-shrink:0;min-width:170px;scroll-snap-align:start}.stat-val,.stat-lbl,.stat-trend{white-space:nowrap}
- Each card lets long labels extend the card’s natural width; the row scrolls horizontally so the user can see all cards.
- This rule applies to dashboard KPIs, summary tiles, metric strips — anywhere multiple stat cards sit side-by-side.
Cards
- Background:
var(--bg-surface)/var(--white). - Radius: 14–16px for list cards, 24px for primary detail cards.
- Border: NEVER. Use
var(--sh-card)shadow only:2.08px 2.78px 16px 0px rgba(193,197,210,0.10). - Don’t use a colored left-border accent for status. Use a tinted footer strip or status badge instead.
- When cards are children of a scrolling flex column, set
flex-shrink: 0on each card so it can’t be squeezed by sibling content.
Device-Appropriate Components
- When designing a mobile flow, always use mobile-specific components:
- App header (
.mhdrpattern — see §9 — status bar + bar with absolute back left, centered title, optional absolute action right) - Bottom navigation tab bar (
.mnav/.mnav-tab— white bg,border-top: 1px solid #ebebeb, brand-green top accent on active) - Bottom sheets (
.sheet-wrap/.sheet— see §9.10 — white surface, drag pill, sheet head with title/sub/close, slide-up animation) - List picker (
.lp-list/.lp-row/.lp-radio) for single-select sheets (banks, stations, etc.) - Full-width stacked layouts — no sidebars
- Touch-friendly tap targets (minimum 44px height)
- App header (
- When designing a web/desktop flow, always use desktop-specific components:
- Sidebar navigation (
.sb-*pattern) - Topbar / page header
- Multi-column layouts
- Hover states on interactive elements
- Sidebar navigation (
- Never mix mobile and desktop navigation patterns in the same flow.
1. Color Tokens
Section titled “1. Color Tokens”CSS Custom Properties
Section titled “CSS Custom Properties”/* Light theme (default) */:root { --brand: #01E46F; --brand-dim: rgba(0,217,100,0.12);
--bg: #fbfbfd; /* Page canvas / outermost background */ --bg-sidebar: #ffffff; /* Sidebar background */ --bg-surface: #ffffff; /* Cards, modals, inputs, elevated surfaces */ --bg-hover: #f0f0f0; /* Hover state surfaces */ --border: #e4e4e4; /* Standard borders */ --border-soft: #ebebeb; /* Subtle dividers */ --text-1: #111111; /* Primary text */ --text-2: #555555; /* Secondary text */ --text-3: #888888; /* Muted / placeholder text */
--sh-sm: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04); --sh-md: 0 4px 8px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04); --sh-lg: 0 8px 20px rgba(0,0,0,.10), 0 4px 8px rgba(0,0,0,.04); --sh-xl: 0 16px 32px rgba(0,0,0,.12), 0 6px 12px rgba(0,0,0,.05);
--r-sm: 6px; --r-md: 8px; --r-lg: 12px; --r-xl: 16px; --r-f: 999px;}
/* Dark theme */[data-theme="dark"] { --bg: #111111; --bg-sidebar: #161616; --bg-surface: #1a1a1a; --bg-hover: #222222; --border: #2a2a2a; --border-soft: #1e1e1e; --text-1: #ffffff; --text-2: #8d8d8d; --text-3: #6b6b6b;
--sh-sm: 0 1px 3px rgba(0,0,0,.20), 0 1px 2px rgba(0,0,0,.14); --sh-md: 0 4px 8px rgba(0,0,0,.22), 0 2px 4px rgba(0,0,0,.12); --sh-lg: 0 8px 20px rgba(0,0,0,.26), 0 4px 8px rgba(0,0,0,.14); --sh-xl: 0 16px 32px rgba(0,0,0,.30), 0 6px 12px rgba(0,0,0,.16);}Page Background
Section titled “Page Background”- The outermost page canvas always uses
background: var(--bg)— never--bg-surfaceor white directly onbody. - Light:
#fbfbfd— off-white, not pure white. This creates visual separation from--bg-surface(#ffffff) cards that float on top. - Dark:
#111111 - This rule applies to
body,.dash-main,.page-wrap, and any full-screen container.
Web page width & wrap (HARD RULE)
Section titled “Web page width & wrap (HARD RULE)”Every web page’s content sits in the canonical wrap — one fixed, consistent spacing rule across all pages:
.pg-wrap { width: 100%; max-width: 1600px; margin: 0 auto; padding: 28px 40px 64px; }width: 100%is required. Without it the wrap collapses to its content width and leaves large left/right gutters (a real bug — a flow read at ~52% of the screen because it was missing this).- Content fills the main area minus a fixed 40px horizontal gutter — the same gutter on every page and every monitor width (that’s the “consistent spacing”).
- Capped at 1600px so ultra-wide monitors keep readable line lengths; beyond ~1690px the wrap centers with a small margin.
- Enforced: the
web-pagewrap-collapselint flags a page wrap withmax-width+margin:autobut nowidth:100%. - Reference apps are grandfathered. Caesar (
.page) and Polaris keep their existing centered ~1320px wrap — they’re the component source of truth, not the width standard. Apply this rule to new flows and any flow you adjust; do not retro-migrate the two reference apps.
Atlas shell parity — copy the starter template (HARD RULE)
Section titled “Atlas shell parity — copy the starter template (HARD RULE)”Every Atlas web flow’s shell must be copied verbatim from the starter template (products/atlas/flows/starter-template/index.html) — it is the single source of truth for the Atlas shell, and all Atlas flows must follow it 100%. The parity set:
| Component | Canon in the starter template | Markers |
|---|---|---|
| Sidebar | .sidebar with grouped .sb-items (.sb-item.active), collapsible rail, red .sb-logout foot |
.sidebar, .sb-item |
| Sidebar logo | .sb-logo > logo mark + .sb-logo-txt > .sb-logo-name (“Atlas”) + .sb-logo-sub = this flow’s catalog category |
.sb-logo-sub |
| Topbar / headbar | .crumb-toggle → functional breadcrumb (current page only; expands to Parent / Current on drill-down) → .loc-pill location indicator → dark-mode toggle → notifications bell → account pill |
.topbar, crumb-toggle, loc-pill, toggleTheme |
| Account / profile dropdown | .acct-menu overlay with the searchable role switcher (Switch role → #role-dlg, .role-opt), Settings, and red Logout |
.acct-menu, .role-opt / #role-dlg |
| Notifications page | #v-notifications with unread/read distinction + working mark-as-read; bell opens it and syncs the dot |
#v-notifications, .ntf-row, openNotifications |
| Settings page | two-pane .settings-layout (nav pane + content pane) |
.settings-layout |
No reduction — additions only. A flow may ADD (extra nav items, extra settings sections, extra notification types, extra breadcrumb levels) but must never remove, shorten, restyle, or re-skin any of the above. The exact design, structure, and functions carry over verbatim; only the flow’s own content changes. A missing, reduced, or re-skinned shell component is a critical violation.
Enforced by the atlas-shell-parity lint rule and, at judgment level, by the design-reviewer agent / /design-review sweep. (Login is exempt — it keeps its own canon below; only the starter template shows a visible login.)
Sidebar logo subtext = the flow’s catalog category (HARD RULE). Under the “Atlas” wordmark, .sb-logo-sub carries the category the flow is filed under in site/src/data/products.ts, title-cased — not the persona, not the product area, not a free-text label. An onboarding flow reads Atlas / ONBOARDING; a swap flow Atlas / SWAP; the VP of Facilities Atlas / FACILITIES. The category set is onboarding · revenue-assurance · swap · vehicle-operations · supply-chain-parts · support · telematics-kaag · facilities · expansion (plus starter-template, whose subtext stays the template’s own label). A unit that holds several functions names both. Vehicle Operations runs maintenance, conversion, production and battery; Supply Chain & Parts runs procurement and the warehouse. A console inside one of those reads the unit’s short from FLOW_CATEGORY_ORDER and the flow’s own fn from the catalog, joined with ›: Atlas / VO › MAINTENANCE, Atlas / SC&P › PROCUREMENT. A seat that runs the whole unit sets no fn and reads the unit alone: Atlas / VEHICLE OPERATIONS. Both halves come from the catalog, so neither can be typed into a flow and drift. This makes the sidebar say, at a glance, which slice of Atlas you are in — and it stays correct automatically because it is derived from the catalog, not invented per flow. .sb-logo-sub hides with the labels under body.sb-collapsed. Enforced by the atlas-logo-sub-category lint rule.
Page + table structure (HARD RULE)
Section titled “Page + table structure (HARD RULE)”Established on the Atlas starter template (products/atlas/flows/starter-template/index.html), which is the reference for all of this.
- No page subtitles. A page head is the
.pg-title(+.pg-actions) only — never a.pg-subline under the title. Enforced byweb-page-subtitle. .psegtabs belong to DETAIL pages only. They switch the sub-sections of a record page (Overview / Telemetry / History). They are never a table filter and never sit inside a.tbl-card. Enforced byweb-pseg-in-table.- Filter a table with dropdowns,
.filt-select+ a.filt-search— not tabs, not chips. - Table card head layout: the filters live inside
.tbl-card-head, never in a separate bar above the card. Search on the left, filter dropdowns on the right (.tbl-card-headis alreadyjustify-content:space-between). - Never repeat the page title as the card title. If
.card-titlewould say the same thing as the.pg-titleabove it, drop it and let the search occupy that slot. Keep the card title only when it names something different (page “Dashboard” / card “Work orders”). Enforced byweb-dup-card-title. - 3 or more filters → collapse to a filter modal. Replace the inline controls with a single
.btn-secfilter-icon button that opens a.dlglisting every filter as a.fld, with Reset and Apply in the.dlg-foot. At 2 or fewer, keep them inline. The button carries a.badgecount of how many filters are active (hidden at zero) and takes.is-set, so an active filter is visible without opening the dialog. Reference:rider-acquisition-managerOverview (3 filters, collapsed) vs its Regions page (2 filters, inline).- A view-mode toggle is not a filter and doesn’t count toward the threshold — it changes what the table shows (By cluster / By swap station), not which rows survive. It stays inline even when the filters have moved into a modal.
- A period picker plus its custom-date-range panel is two controls, not one. That pair alone is most of the budget, which is why it belongs in the modal as a single Period
.fldwhoseCustom date rangeoption reveals a.fld-rowof From/To. - Applying must leave no stale state: the dialog writes the same globals the inline controls wrote, then re-runs the render. Dismissing (Escape, backdrop, ✕) keeps what was last applied — only Reset clears it.
- A filter hidden in a modal must be stated on the page. When filters live behind an icon, the page still has to say what it’s showing, or the numbers read as unfiltered. Put the canonical removable chips (DS components § Removable Chips) on the
.pg-head’s own row, inside.pg-title-rowafter the.pg-title— aFiltered bylabel + a.chip-groupof.chip.chip--removable(one per active filter, each with its own.chip-remove✕ that clears only that filter), plus a.chip--recentClear all once two or more are active. The whole bar is absent when nothing is filtered — aFiltered bylabel with no chips is worse than no bar. Reference:rider-acquisition-manager#ov-filter-summary. - One state writer. Apply, Reset and clearing a single chip all route through one function that rewrites the KPIs, their period labels, the card title, the chips and the button count together. Three call sites each restating the strings is how a label ends up disagreeing with the number under it.
- Defaults are not filters.
This week/All clustersproduce no chip and no count. The count badge and the summary bar derive from the sameactiveFilters()list, so they can’t disagree.
- Inner pages always carry a back button. Any page not reachable from the sidebar (a record/detail page, Notifications, Settings) starts with a
.pg-backbutton that returns to the page the user came from and re-activates that sidebar item. Never strand the user on an inner page. - Every filter actually filters and the
.tbl-footcount reports the filtered total — no decorative controls, no hardcoded counts that contradict the rows. - A chart must be hoverable. A static SVG with no readout makes the reader estimate values off gridlines. Use the canonical
.chart-tipcontract (reference:products/caesar/screens/dashboard.html): one.chart-tipelement on<body>, delegatedmouseover/mousemove/mouseout, and hover targets carryingdata-tip-l(label) +data-tip-rows(key|colour|value;…). Give a line chart one invisible band per x-position spanning the full plot height — a 4px dot is not a target — and reveal the guide line and enlarged dots with CSS:hoveron the band rather than driving them from JS. - A whole table row is the click target, not the chevron. Put the handler on the
<tr>;.tbl tbody tr[onclick]{cursor:pointer}andtr:hover td{background:var(--hover)}are already canon. Keep the trailing chevron as an affordance but give itonclick="event.stopPropagation();…", or it fires the handler twice. Verify by counting calls from a row-body click and a chevron click — both must be exactly 1. Audit every table in the flow, not just the one under review. - A bare percentage next to a bar has two denominators. The bar is a share of the leader; the chip is change vs the prior period. Nothing on screen says which, and readers ask. Name the comparison in the column header (
Onboarded · vs prior period) and put both raw numbers in the chip’stitle. - One trend atom per repo.
.trend+.trend-up/.trend-down/.trend-flat/.trend-neutralwith the SVG arrow. A second component with▲/▼text glyphs (.trend-pill) is drift — fold it in. Text-glyph arrows don’t inherit stroke weight and render differently per platform. - Declare render-time constants above every render.
varhoists the name, not the value, so an arrow/icon constant declared below a table that renders on load resolves toundefinedinside the row HTML — and it looks correct on any view that re-renders later, which is how it survives review. Grep the rendered DOM forundefinedas a cheap catch. - A dashboard needs direction, not only levels. Four static KPI numbers and a ranked list tell you where you are and nothing about which way you’re going. Every overview carries: a delta per KPI against the prior period (
.trendchip on the value’s own line via.pstat-valrow, so it costs the row no height), a trend chart (canonical.chart-row>.card>.chart-head+<svg class="chart-line" viewBox="0 0 700 210">, plot x 30→660, y 190→24, area in--brand-dimunder a--brandline, end dot,<text>x-labels — reference: starter template), and a breakdown card in the 1fr slot beside it (.cbarlist) so the total decomposes.- Derive the totals from the series, never type both. Author the daily/weekly arrays and compute Today / this week / MTD from them — the last daily point is today, the daily sum is this week, the weekly sum is MTD. Typing the totals separately guarantees the chart and the KPI eventually disagree. Assert the sums (
rider-acquisition-managerOV_BY_CLUSTER→ovFigures), and assert the breakdown card sums to the KPI it decomposes. - Direction is not always good or bad. A shrinking pipeline can mean faster conversion, so give each metric a
goodDirand render the ambiguous ones.trend-neutral(grey) instead of red. A zero delta is.trend-flatwith no arrow, not a green 0%. - A chart’s window is often not the KPI period (a trailing 7 days beside an MTD figure). Print the window on the card — “Leads vs onboarded · last 7 days” — rather than leaving it inferred.
- A scoped breakdown dims, it doesn’t drop. Filtering to one cluster should mute the others in the split card, not remove them; “18 of 94” is only legible with the rest on screen.
- Derive the totals from the series, never type both. Author the daily/weekly arrays and compute Today / this week / MTD from them — the last daily point is today, the daily sum is this week, the weekly sum is MTD. Typing the totals separately guarantees the chart and the KPI eventually disagree. Assert the sums (
- The card names itself — no
.section-headerabove a.tbl-card. Title and count go in the left slot of.tbl-card-head(.card-title+.card-sub), and the card’s one control — a “View all →”.section-link, a view-mode.filt-select, or the search — takes the right. A separate header row above the card spends a full row of vertical space saying what the head can say inside it..card-subisdisplay:blockso it sits under the title.- Anything inside a
.tbl-card-headis a dropdown, never a.pseg. Moving a title into the card brings its sibling controls with it, and a.psegin a table card reads as a tab strip filtering the rows —web-pseg-in-tablewill catch it. Convert the toggle to a.filt-select. - Search keeps the left slot only when there is no title. With a title present, search moves right; that is the same one-slot rule as “never repeat the page title as the card title”, read the other way round.
- Anything inside a
- The login is the starter template’s, verbatim, in every Atlas flow. It stays dormant (the flow boots to the dashboard) and logout reveals it — but when it is revealed it must be the starter’s scene login, not a per-flow variant. Copy three things, not one: the markup, the
.login-*CSS, and the container rule#<id>{position:fixed;inset:0;z-index:500;background:var(--grey1);overflow:hidden;}with#<id>.active{display:block;}. Keep each flow’s own container id and its own enter-app handler; translate token names only — a flow whose--grey1is#FBFBFDshould render on#FBFBFD.- A class-based CSS sweep misses id selectors. Replacing every
.login-*rule left#s-login{flex-direction:row}behind in 27 flows, so.login-stageshrank to content width and the card sat left instead of centred. When you transplant a component, sweep the rules that style its container too, not just the ones named after it. display:flexon the container breaks the centring. The starter’s container isdisplay:blockand.login-stage(height:100%, flex column, centred) fills it. As a flex container, the stage becomes a flex item sized to its content.- Verify against the reference’s computed geometry, not a screenshot. Read the reference’s own card box (416×545 at x=505) and assert the flow matches it; a ground-colour difference looked like the bug when the real defect was geometry.
- A transplant carries the source’s tokens and helper classes with it. Flows written against another vocabulary resolved
var(--grey1)/var(--surface)to nothing and rendered.badge/.btn-priunstyled. Loop until closure: pull missing rules, then the tokens those rules need, and count a linked stylesheet as part of the pool.
- A class-based CSS sweep misses id selectors. Replacing every
.tbl-card + .tbl-card{margin-top:20px}is a vertical-stack rule and it leaks into grids. Grid siblings are “adjacent” too, so the second card in a row inherits a top margin, sits 20px lower and measures 20px shorter than its neighbour inside a stretched row. Zero it per grid (.grid>.tbl-card+.tbl-card{margin-top:0}) and setalign-items:stretch. When two cards in a row look unequal, measure both heights before touching padding — the cause is usually a sibling margin, not the content.- Every initial render is called from one boot block at the end of the script, never at its point of definition. A renderer defined above the
consts it reads hits them in their temporal dead zone: a ReferenceError that aborts the rest of the script silently — no console entry, the page still paints, and only the work after the throw is missing.typeof xthrowing rather than returning"undefined"is the tell that a binding is in TDZ, not absent. - Derive a page’s money from the counts the rest of the flow already uses. Typed-in currency totals drift from the roster the moment either changes. Name any scale-up factor explicitly, and assert the parts sum to the whole (a by-cluster breakdown must total the recognized figure).
- A KPI row is one row, and its cards are one set. If cards wrap to a second row, the problem is usually that they aren’t comparable, not that they need resizing. Cards in a row must partition the population: every record in exactly one, and the values summing to the total — assert it (
nums.sum() === records.length). A rollup sitting beside two of its own members, or a cross-cutting flag beside a funnel stage, double-counts and reads as peers. Keep the partition on cards; put nested and cross-cutting buckets in a.chiprow underneath (.chip-countfor the number,aria-pressedfor the active one) — same click behaviour, a fraction of the weight.- Summing the cards also finds holes: seven cards on the Riders page matched no
Onboardedrider at all, so two riders past onboarding but not yet Active appeared in no card.
- Summing the cards also finds holes: seven cards on the Riders page matched no
- A dropdown inside a
.dlgmust beposition:fixed..dlg-bodyisoverflow-y:autoand.dlgisoverflow:hidden, so an absolutely-positioned menu is clipped twice over. Fixed escapes both, but is then positioned against the viewport — feed it the trigger’s live rect, flip it above the field when the space below can’t hold it, re-place it onscroll/resize(capture phase, since the scroll happens on.dlg-body), and give it az-indexabove the overlay’s. - Fold columns into sub-lines before you shrink the type. Ten columns at 1440 leaves each about 100px, which is what makes a table read as congested. Pair each field with the one that qualifies it and put the second on a
.td-subunder the first — phone under the rider’s name, days-in-status under the status badge, last-active under the lead date. Nothing is dropped, the row still carries every field, and the columns breathe. Name the pairing in the header (Rider · phone) so the sub-line isn’t a surprise, and keep the.th-subshort enough to fit (.tbl this nowrap). - A
.card-titlethat repeats the.pg-titleis not a title. Drop it and give the slot to the count or the search.RidersoverRidersis a wasted row. - A count in the card head and a window in the foot are different numbers. The head says how many rows the filters left (
13 of 30 riders); the foot says which page you’re looking at (Showing 1–10 of 30). Don’t make one do both, and don’t hardcode either. - Adjacent numeric columns share one alignment rhythm. Numbers are right-aligned (
th.r/td.r). A right-aligned column next to a left-starting one puts the two values near each other while their headers sit at opposite ends of their columns — the edges measure perfectly and it still reads as misaligned. Check the rhythm across the row, not just each column against its own header.- A value and its delta need two fixed sub-columns. Right-aligning
13 ▲18.2%as one group lets a wider delta (“28.6%” vs “0%”) push its number left, so the column of numbers comes out ragged. Use.num-delta—inline-gridwithgrid-template-columns:auto <fixed>— so the numbers form one edge and the deltas another. - Don’t borrow
.cbar-topfor a cell with no bar. It is a bar’s label row (space-betweento put a value and its end-label at opposite ends of the track). In a bar-less cell it just pushes the two apart and breaks the column’s alignment. Only use it above a.cbar-track. - Measure painted text, not boxes. A
Rangeover the cell’s contents gives the real glyph edge; agetBoundingClientRect()on the<td>only tells you where the cell is, which is identical whether or not the content inside it lines up.
- A value and its delta need two fixed sub-columns. Right-aligning
- Show the rate, not just the two raw numbers. A performance table listing leads and a lifetime rider count shows the top of the funnel and a stock, and hides the only number that judges the person: what they converted. Derive it (
onboarded ÷ leads, both period-scoped) and give it its own column. Sorting by the rate rather than by volume is a product decision — volume rewards scale, the rate rewards quality — so make it deliberately and keep the volume column visible beside it.- A percentage bar is absolute, never relative to the leader. A rate already carries a 0–100 scale; rescaling it to the top row turns 90% and 45% into a full and a half bar and destroys the meaning. Relative-to-leader is for counts (a leaderboard), absolute for rates.
.tbl thiswhite-space:nowrap, so header text must fit its column. A long.th-subruns a nowrap header over its neighbour. AssertscrollWidth <= clientWidthon everyth— that is the reliable check; comparing a header’s right edge to the next cell’s left double-counts padding, becausescrollWidthalready includes it. When a header won’t fit, move the detail into the cell’s own hover tooltip or athtitlerather than shrinking the type.- The same word must not mean two things in one flow.
Pipelinemeant leads-created on one card and “not yet onboarded” on a KPI beside it. Name the funnel stage. - Scale a synthetic custom span from the WEEKLY rate, not the daily one. Scaling one day’s figures multiplies numerator and denominator equally, so any derived rate collapses to that day’s — which surfaced as several agents at a suspicious exactly-100%. From the weekly rate a 7-day span reproduces the week exactly and a 14-day span doubles the volume while holding the rate. Use one rule across every page in the flow.
- A popover must not be wider than its trigger. A 280px panel anchored to a 160px control spills sideways past the thing that opened it and reads as broken — it stays inside the page, so no overflow check catches it. Measure the panel against its trigger, not against the viewport. When the content genuinely needs more width than the control (a From/To pair does), it is not a popover: use the centred
.dlg. There is no canonical date-range popover in the DS —.date-filter*is bespoke and had spread to six flows. - A card called “ranking” must be sorted by the ranked metric. Don’t trust source order — sort a copy in the renderer, or rank 1 lands on whichever row the data happened to list first and the bar scale disagrees with the chips. One dataset in this flow was pre-sorted and its sibling was not, so the bug only appeared in one of the two view modes.
- One control per concept. A period
.filt-selectnext to a “Custom date range” button prints the same words twice. Fold the custom option into the select and let it open the date panel; the applied span then becomes that option’s own label, so the control shows its own state. Delete the button’s CSS and its toggle handler with it. - A hardcoded
.tbl-foot.Showing 0 of 0and a‹ Prev 1 Next ›typed into the markup on a table whose rows are rendered from JS will never update, and lint cannot see it — the count and pager are either rendered from the same data as the rows or they don’t exist. Hide the whole.tbl-footwhen there is one page; a disabled Prev/1/Next is noise. (hiddenworks here only because[hidden]{display:none!important}is declared —.tbl-footsetsdisplay:flex.) - A ranking gets a bar, not four columns of digits. A “top N” card is a ranking, and a table of bare numbers makes the reader compare digits to recover the order. Use the bar-in-cell treatment (
.hcell+.cbar-track>.cbar-fill, reference:facilities-vpRegions,rider-acquisition-managerTop agents): a rank chip (.ag-rank, tinted--brand-dimon first place only), name + context as.td-title/.hcell-subso the category column folds away, and the ranked metric as a bar whose width is value ÷ leader so first place always reads full and the gap below it is the story. Print the raw value above the bar; never let the bar replace the number.- Reach for an actual chart only when the card carries one metric and no row action. The DS Horizontal Bar suits a ranked list, but it cannot hold a per-row drill-in or a second metric — bar-in-cell keeps both.
- A top-N widget has no pager. Pagination over a leaderboard hides the tail behind a click and produces absurdities like a page 2 holding one agent; show them all and let the “View all” link own the full list.
- Rank and bar scale are derived from the filtered set, so ranks re-number to 1..n and the new leader’s bar goes full when a filter narrows the rows. Guard the divide — a filtered set can be empty or all zeros.
- Distinguish a flow from a stock. A period count (onboarded this week) and a lifetime total (active riders) side by side read as comparable unless labelled; mark the cumulative one (
Active riders · total).
- A filter applies to everything on the page, not just the table. If a page-head filter narrows the table but leaves the KPI row on country totals, the KPIs are now wrong — and if a KPI sub-label names the filter (“This week · North”) while its digit is still the unfiltered one, it states something false. Check every figure on the page against the filter, not only the rows. When the KPI data has no dimension for the filter, add the breakdown rather than dropping the filter: give the dataset the cluster/segment axis and derive the totals by summing it, so the parts and the whole cannot drift (
rider-acquisition-managerOV_BY_CLUSTER→ovFigures(range, scope); the four clusters sum exactly to the shipped 226/94/3180/412). Sanity-check that no scoped KPI comes out below the rows visible beneath it.- The filter summary costs no vertical space.
.pg-actionsalready makes the head row 38px and a chip is 33px, so chips on the title row fit inside the existing height: applying a filter must not move the KPI row, the section header or the card. MeasuregetBoundingClientRect().topof each in both states — it has to be identical. A summary bar placed as its own block between the head and the content pushes everything down 48px the moment a filter is applied, and one placed below the KPI row also reads as belonging only to the table underneath. - State the scope once. The applied-filter chips already name it for the whole page, so the KPI subs don’t each repeat it — appending “ · Lagos Island” to a long sub wrapped it to a second line and grew the KPI row 18px the moment a cluster was picked. Measure the row height in both states; a filter must not reflow the page.
- The filter summary costs no vertical space.
- A label printed on a number must describe that number. If a KPI carries a period sub-label, or a card title names the active period, changing the period has to move the digits too — a “Jul 1 → Jul 14” label sitting over last selection’s total is a lie the user can read off the screen. Give the period one label function and one KPI writer, and call them from every path (apply, reset, load) instead of restating the strings at each call site. Acronyms keep their case:
MTD, nevermtd. - Native date/time inputs need
color-scheme:darkon the dark root.<input type="date">renders its calendar popup and indicator icon from the UA colour scheme, not from our tokens, so under a dark theme without it the popup comes back light-on-dark. Any flow with a dark theme and a native date/time input declarescolor-scheme:darkin its:root[data-theme="dark"](orbody.dark) block. Enforced byweb-dark-color-scheme. - Escape closes every dialog. The one
keydownlistener names every open-able overlay in the flow, not just the ones that existed when it was written — add the new dialog’s close function when you add the dialog. A.dlgthat only closes by ✕ traps keyboard users. hiddendoes not hide a classed element. The UA rule[hidden]{display:none}is a bare attribute selector — specificity (0,1,0) — and a class is also (0,1,0), so.badge{display:inline-flex}ties it and wins on source order. Marking a.badge/.chip-grouphidden therefore leaves it rendering, usually as a stray empty pill. Every flow declares[hidden]{display:none !important;}near the top of its stylesheet (the starter template now does); a component-scoped.thing[hidden]{display:none}is (0,2,0) and also works. Enforced byweb-hidden-attr-beaten. When verifying, read the computeddisplay, neverel.hidden— the property istruein exactly the broken case.- Copy an icon from the reference, don’t redraw it. The canonical
.dlg-close✕ iswidth="16" height="16" viewBox="0 0 24 24" stroke-width="2"with the18,6 → 6,18line pair. A hand-rolled20×20/2.2variant reads visibly heavier next to a canonical one. Diff theouterHTMLagainst the reference dialog in the same flow. - A page-level note sits IN the page head, opposite the title — never stacked underneath it. An instructional/context pill (
.iq-noteand friends) goes in the.pg-actionsslot of.pg-head, so it is right-flush with the table card below and baseline-aligned with the.pg-title..pg-headis alreadydisplay:flex; justify-content:space-between; align-items:flex-end— the note just needsflex-shrink:0and nomargin-bottom. A note stacked between the head and the content wastes a full row and pushes the table down.
Form fields are .fld — the login’s .inp must not leak (HARD RULE)
Section titled “Form fields are .fld — the login’s .inp must not leak (HARD RULE)”Every field in the app — dialogs, settings, wizards, filters-in-a-modal — uses the canonical .fld block:
<div class="fld"><label>Category<span class="req">*</span></label><select>…</select></div><div class="fld-row"> … two fields side by side … </div>.fld input/select/textarea = 42px · 1px border · --r-md (8px) radius · brand focus ring, and .fld select adds appearance:none + the DS chevron data-URI at right 12px center.
The .inp / .form-group / .form-label trio belongs to the dormant login only (44px, 10px radius, 1.5px border, and no appearance:none, so a <select> renders the native OS arrow). Flows built before the sweep leak it into dialogs; converting is part of every sweep:
| leaked | canonical |
|---|---|
<div class="form-group"> |
<div class="fld"> |
<label class="form-label"> |
<label> (styled by .fld > label) |
class="inp" on the control |
drop it — .fld input/select/textarea styles it |
<div class="form-2col"> |
<div class="fld-row"> |
inline settings select (.inp .set-inp-sm) |
.filt-select (the DS compact select) |
Enforced by web-noncanon-form-field, and by select-native-arrow for any styled <select> missing appearance:none. Watch the multi-class variants — class="inp mono", class="form-group mb16" — an exact-string replace misses them; strip the token, keep the siblings.
No side drawers — modal or details page (HARD RULE)
Section titled “No side drawers — modal or details page (HARD RULE)”Side drawers / slide-overs / side panels are banned on web. There are exactly two ways to create, edit or view a record:
- Modal — the canonical centred
.dlg(.dlg-overlay>.dlg>.dlg-head/.dlg-body/.dlg-foot). Use it for create, edit, and compact view. - Details page — a full
.pg-viewwith.pg-back,.pg-head/.pg-title, and.psegsub-section tabs. Use it for a rich record with multiple categories of information.
Never a .drawer / .drawer-overlay / slide-over / side panel, and never a detail pane pinned open beside the table. Detail always opens on row click into a .dlg or a details page. Enforced by the web-no-drawer lint rule.
Details-page anatomy — copy it from the starter template (#v-rider-detail, #v-battery-detail):
.pg-view .pg-back ‹ {parent page} ← always first .pg-head .pg-title-row > .pg-title + status .badge ← status sits WITH the title .pg-actions > buttons only (NO subtitle anywhere) .pstat-row > .pstat the record's headline numbers .pseg > button page-level sub-sections → detailTab(this, i) .rec-panel(.on) one per .pseg button .rec-grid > .rec-col two columns, collapses to one under 900px .card > .card-eyebrow .td-row > .td-k (.td-ic + label) + .td-v ← default field row .fr > .fr-label + .fr-val ← only when an icon key doesn't fit .prof-tabs > .prof-tab + .prof-panel ← tabs INSIDE one card → cardTab(this, i) .tbl-card a sub-tab may hold a full table + .tbl-foot .card > .empty-wrap empty sub-tab — ALWAYS inside a white .card, never bare on the page- The status badge belongs to the TITLE, not the actions. Wrap the title line in
.pg-title-row(display:flex; align-items:center; gap:10px) and put the record’s status.badgeimmediately after the.pg-title..pg-actionsthen holds buttons only. A status pill parked next to a primary button reads as a second control and collides with it. .pseg= page-level sub-sections;.prof-tabs= tabs inside one card or one.dlg. Never use both for the same level, and never underline tabs for either.- The page title is the record’s identifier (the rider’s name,
BAT-7742). If you also render a.rec-ididentity block, it must not repeat the title. - Opening a record resets the sub-tabs to the first panel, sets the breadcrumb to
{Parent} › {Record}, and scrolls to top..pg-backand the breadcrumb parent both return to the list. detailTab()scopes withbtn.closest('.pg-view')andcardTab()withbtn.closest('.card')— never by hardcoded id, so the same helper serves every details page in the flow.
Typeface end to end — the SF Mono stack does NOT load (HARD RULE)
Section titled “Typeface end to end — the SF Mono stack does NOT load (HARD RULE)”General Sans is the only typeface that renders. It is loaded from fontshare; no mono webfont is loaded anywhere in the design system, and the starter template uses zero mono. A declaration of 'SF Mono','Fira Code','Cascadia Code',monospace therefore resolves to none of them — it falls through to the browser’s generic monospace, which renders as Courier. That is off-brand, and it silently affected 47 elements in one flow before it was caught.
- Serial numbers, VINs, plates and codes stay in General Sans, with
font-variant-numeric: tabular-numsandletter-spacing:.02emfor the aligned, technical read. That is the canonical.monotreatment:.mono, code { font-family: inherit; font-variant-numeric: tabular-nums; letter-spacing: .02em; } - Style bare
<code>too — it inherits the browser’s monospace default and will otherwise render in Courier. - Never add another webfont to make a mono stack work; the typeface rule is General Sans only.
- Verify by computed style, not by declaration. Walk the rendered page and assert every visible element resolves to a family containing
General Sans(ignorehtml/head/meta/title/link/style/script). A declared stack proves nothing about what actually paints.
Running a design sweep on an existing Atlas flow (HARD RULE)
Section titled “Running a design sweep on an existing Atlas flow (HARD RULE)”The starter template is the skin — a sweep re-skins the flow onto it, it does not hand-pick atoms. Work in this order and verify each step in the browser, not by eye:
- Diff every shared selector against the starter and overwrite the flow’s with the reference’s. Do not cherry-pick. Exclude only
html/bodyand the (dormant) login. A partial transplant is the #1 source of breakage: a canonical class used in markup whose rule never came across renders unstyled. - After any CSS transplant, assert every canonical class the markup uses actually has its rule — matching the class token with a word boundary. A naive substring check is satisfied by a longer name:
.ic“appears” inside.ic-green, so a flow whose markup wraps every glyph in<span class="ic">passed the check with no.icrule at all and rendered 167px icons. Use\.name(?![\w-]). Classes silently lost this way in a real sweep:.sidebar(base rule — only the collapsed variant came over),.user-name,.user-role,.filt-select,.sb-count,.ntf-sec,.tbl-foot,.pager,.card-title. - Never copy a rule out of an
@mediablock. Take the BASE declaration. Grabbing@media (max-width:820px){.settings-layout{grid-template-columns:1fr}}is what makes a two-pane Settings render “folded” into one column. - Rename the flow’s state classes to canon and update the JS that drives them —
.sb-item.on→.active,is-sidebar-collapsed→sb-collapsed. A missed rename leaves nav highlighting or collapse silently inert. - Tokens: add the canon vocabulary (
--surface,--ink,--grey1/2,--hover,--g-dark,--sh-card,--*-dim) and correct off-palette brand values (a flow shipped#00d964; brand green is#01E46F). - Page canvas is
--bg: #F5F6FA, and the typeface is General Sans loaded from the same fontshare link the starter uses. - Sidebar: keep the flow’s existing nav structure — do not invent
.sb-groupheadings it never had. Settings is never a sidebar item; it lives in the account dropdown. Counts use.sb-count, which must hide with the labels underbody.sb-collapsed. - Page head:
.pg-head>.pg-titleholding the page name. Never a greeting or personalised string (“Good morning, Ngozi” → “Overview”), and never a subtitle. - Settings and Notifications pages are copied from the starter verbatim — same markup, same sections, same nav. Only persona details (name, email, initials) change.
- Tables: every table gets the canonical
.tbl-foot(.lblcount +.pager). Filters are.filt-selectdropdowns in the.tbl-card-head— title left, then search + filters right when the card names something other than the page (the dashboard case); search left, filters right when the title would repeat the page title and is therefore dropped. Never a.filt-barfloating above the card, and never.psegtabs. - Check the shell’s layout contract. If the flow wraps the shell in a flex row, the canonical
.main(written for a fixed sidebar + block parent) sizes to its content and pages collapse to ~half width — give itflex:1;min-width:0. - Transplanted markup needs its JS and its CSS. Copying a page from the starter (Settings, Notifications) brings markup that calls functions (
setNav,tgl,pgBack,openRoleModal). After any copy, assert BOTH: everyon*="fn("handler resolves to a defined function, and every class used has a rule. Re-scope copied JS to this flow’s ids — the starter’ssetNavtargets#v-settings, so a flow using#pg-settingsgets a nav that looks perfect and does nothing. Also watch for a selector present in BOTH a grouped rule and its own rule (.fld select): a first-match diff keeps the grouped one and silently drops the specifics. - Verify by measurement, not screenshots: page width on every page, sidebar 248↔68 collapse, no horizontal overflow, every filter actually filtering, and the div balance of any region you spliced.
- Measuring anything with a CSS transition (the sidebar rail,
.mainmargin): kill the transition first, don’t sleep. An in-pagesetTimeoutis throttled in the preview pane, so a “wait 260ms” read still lands mid-animation and reports the start value — the collapse then looks broken when it is fine. Inject*{transition:none !important}, toggle, read, remove:Cross-check withconst kill=document.createElement('style');kill.textContent='*{transition:none !important;animation:none !important;}';document.head.appendChild(kill);/* toggle + read here */ kill.remove();getComputedStyle(el).width, which is what the rule actually resolves to.
- Measuring anything with a CSS transition (the sidebar rail,
Sweep baseline — staged rules are zero-tolerance once swept (HARD RULE)
Section titled “Sweep baseline — staged rules are zero-tolerance once swept (HARD RULE)”The six sweep-staged rules are warnings up to the count in site/scripts/canon-baseline.json for that (file, rule), errors above it. A blanket flip to error would fail CI on ~24 unswept flows; leaving them plain warn let six already-swept flows silently regress before anyone noticed. The baseline gives both: new drift and regressions are hard failures, the grandfathered backlog stays advisory.
- After sweeping a flow:
node site/scripts/lint-canon.mjs --all --update-baseline, then commit the baseline. The flow drops to 0 and can never regress unnoticed. - A finding above baseline prints
[above sweep baseline]so it’s obvious why it’s an error. - Never hand-edit the baseline upward to silence a finding — that is the one move it exists to prevent.
Auth / sign-in screen (HARD RULE)
Section titled “Auth / sign-in screen (HARD RULE)”Flows do NOT gate on login — the first screen is the dashboard. A persona flow must open directly on its main dashboard/app screen; a login/sign-in screen must never be the default (first) screen. Boot to the dashboard (run the sign-in transition on load; the login screen must not carry the default active class). The only flow that keeps a visible login is the starter template (marked canon:allow-login), retained as the canonical login reference. Enforced by the web-flow-login-default lint rule.
When a login IS genuinely needed (rare), it uses the ONE canonical Atlas login — the composed scene on a grey ground, a single centred column. No bespoke variants, and no two-column split: the peach split was replaced in fed28ea/c8aa34e. Full structure + markup is in PATTERNS.md → “Login — canonical Atlas composed scene”; build from that, changing only the product name and tagline. In short:
-
Ground:
var(--grey1)on#scr-login— never peach#FFF4EB/--login-bg(the superseded v0 ground), white, or a dark/blue panel.#scr-login { background: var(--grey1); } -
Structure:
#scr-login>.login-scene(inert,aria-hidden,pointer-events:none) +.login-stage(centred column with the form). The scene layers a.login-texdot grid, a blurred.login-poolof--brand-dim, three.login-halorings, and.login-floatcards on.t-far/.t-mid/.t-neardepth tiers. -
The card is flat.
.login-cardcarriesbox-shadow:none— depth comes from the scene, not the card. -
Decoration is neutral. Shadow tints are
rgba(17,17,17,…)and the texture is#DEDEE4; the warm#E2D3C2texture and--amber-dimpool were peach-era values, neutralised with the ground. -
NEVER
.login-left/.login-right,flex-direction:row, or the bespoke classes.login-wrap,.login-hero-title/.login-hero-sub,.login-signin,.login-foot— those are the split and its re-skins. -
Enforced by three lint rules:
web-login-bg-noncanon(ground must be--grey1, not peach/white/dark),web-login-noncanon-structure(flags the bespoke wrappers above), andweb-login-split(flags a.login-left/.login-righttwo-column split).
Base Tokens Summary
Section titled “Base Tokens Summary”| Token | Light | Dark | Use |
|---|---|---|---|
--bg |
#fbfbfd |
#111111 |
Page canvas |
--bg-surface |
#ffffff |
#1a1a1a |
Cards, modals, inputs |
--bg-sidebar |
#ffffff |
#161616 |
Sidebar |
--bg-hover |
#f0f0f0 |
#222222 |
Hover states |
Semantic Color Scale (Step [9] = canonical action color per family)
Section titled “Semantic Color Scale (Step [9] = canonical action color per family)”| Family | Step [9] | Use |
|---|---|---|
| Brand Green | #01E46F |
CTAs, interactive states — not for status |
| Green | #30A46C |
Success states, healthy indicators, battery OK |
| Blue | #0090FF |
Info states, online/connected indicators, links |
| Red | #E32627 |
Inline validation errors, form errors |
| Tomato | (step 9) | Destructive actions, critical alerts, delete buttons |
| Yellow | #FFE629 |
Warning, low battery, pending swap — use sparingly |
| Amber | #FFC53D |
Moderate-urgency badges, attention-needed tags |
| Purple | #8E4EC6 |
Role/permission badges, category labels only |
Rules:
- Use brand green (
#01E46F) for interaction semantics only (buttons, links, focus)- Use Green scale for status semantics (online, healthy, success)
- Never use Yellow as body text color — contrast fails
- Never put green text on white or very light backgrounds
- Purple is for classification only — never for status indicators
2. Typography
Section titled “2. Typography”Typeface: General Sans (loaded via Fontshare API)
Fallback stack: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif
Monospace: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace
Weights available: 400 Regular · 500 Medium · 600 Semibold · 700 Bold
Display Scale (headings, hero text)
Section titled “Display Scale (headings, hero text)”| Token | Size / Line-height | Tracking |
|---|---|---|
display-2xl |
72px / 90lh | −2% |
display-xl |
60px / 72lh | −2% |
display-lg |
48px / 60lh | −1% |
display-md |
36px / 44lh | — |
display-sm |
30px / 38lh | — |
display-xs |
24px / 32lh | — |
Text Scale (body, labels, captions)
Section titled “Text Scale (body, labels, captions)”| Token | Size / Line-height |
|---|---|
text-xl |
20px / 30lh |
text-lg |
18px / 28lh |
text-md |
16px / 24lh |
text-sm |
14px / 20lh ← default body |
text-xs |
12px / 18lh |
Base body font-size is 14px. Use
text-xs(12px) for captions and metadata.
3. Spacing Scale
Section titled “3. Spacing Scale”Base unit: 4px. All spacing must be a multiple of 4. Never use arbitrary values like 7px, 11px, or 15px.
| Token | Value | Common use |
|---|---|---|
space-1 |
4px | Tight gaps, icon padding |
space-2 |
8px | Component inner gaps |
space-3 |
12px | Component inner padding |
space-4 |
16px | Standard inner padding |
space-5 |
20px | Card padding |
space-6 |
24px | Section gap, card spacing |
space-8 |
32px | Between groups |
space-10 |
40px | Section spacing |
space-12 |
48px | Page-level section gaps |
space-16 |
64px | — |
space-20 |
80px | — |
space-24 |
96px | — |
Rules:
- Interactive component inner padding:
space-2(8px) tospace-4(16px)- Between major page sections:
space-8tospace-12
Sibling spacing rule (gap between elements)
Section titled “Sibling spacing rule (gap between elements)”The gap between two elements on the same screen is driven by how related they are, not by what fits the layout:
| Relationship | Gap | When |
|---|---|---|
| Section head → its first card | 8–12px (space-2–space-3) |
A section title labels the content immediately below it. They read as a unit. Pull the card up so the relationship is visually obvious (.rsm-section-head + * { margin-top: -10px } against the body’s 18px gap is the canonical retrofit-app override). |
| Same kind, stacked | 16px (space-4) |
Two or more cards/rows of the same type (e.g., a list of MEI components, a list of step cards, a list of session-header sections, a list of crew cards). Default. |
| Same kind, tightly grouped | 8px (space-2) |
Rare. Use only when the items read as a single composite (e.g., adjacent score chips, a row of photo tiles). |
| Different kinds (not label-to-content) | 24px+ (space-6 upward) |
A hero card → a section head, a content block → a bottom CTA. Pick higher values (32–48px) for stronger separation between major sections. |
Implementation:
- Wrap a stack of similar items in a flex column with
gap: 16pxand let the parent body keep its larger gap for transitions between unlike blocks. - For section-head-to-content: prefer a negative
margin-topon the next sibling so flexgapand the label still cooperate (don’t fightgapwith positive margins — they compose unevenly across browsers). - Don’t sprinkle ad-hoc
margin-topeverywhere; pick one source of vertical rhythm per screen.
4. Shadows & Elevation
Section titled “4. Shadows & Elevation”Use shadows only to separate floating/interactive layers from the base surface — never decorative.
Shadow Tokens (light theme)
Section titled “Shadow Tokens (light theme)”| Token | Value |
|---|---|
--sh-sm |
0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04) |
--sh-md |
0 4px 8px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04) |
--sh-lg |
0 8px 20px rgba(0,0,0,.10), 0 4px 8px rgba(0,0,0,.04) |
--sh-xl |
0 16px 32px rgba(0,0,0,.12), 0 6px 12px rgba(0,0,0,.05) |
Dark mode: shadow opacity increases 2–3× (see CSS custom properties above).
Card-specific shadow
Section titled “Card-specific shadow”For .tbl-card and most content cards, use this precise shadow (not the generic tokens):
box-shadow: 2.083px 2.777px 15.967px 0.69px rgba(193,197,210,0.0266);This is a very subtle, cool-tinted shadow that matches the Rechaj card visual language.
Backdrop Blurs
Section titled “Backdrop Blurs”| Token | Value | Use |
|---|---|---|
blur-sm |
blur(4px) |
Chip backgrounds, subtle tinting |
blur-md |
blur(8px) |
Sticky headers, floating nav |
blur-lg |
blur(16px) |
Modals, side sheets, popovers |
blur-xl |
blur(24px) |
Full-screen overlays |
Dark mode frosted surface: pair
blur-mdwithbackground: rgba(17,17,17,0.7)
5. Border Radius Reference
Section titled “5. Border Radius Reference”| Context | Radius |
|---|---|
| Chips, pills, full-round | 999px (--r-f) |
| Cards, sheets, large surfaces | 16px (--r-xl) |
| Buttons, inputs, dropdowns | 8px (--r-md) |
| Small tags, badges, tooltips | 4px–6px (--r-sm) |
| Avatars, dots | 50% |
6. Card Design
Section titled “6. Card Design”Rule: Cards never have a visible border/stroke. Shadow only.
Cards use background: var(--bg-surface), border-radius: var(--r-xl) (16px), and the card shadow below. Do NOT add border: 1px solid var(--border) to cards.
.tbl-card { background: var(--bg-surface); border-radius: var(--r-xl); /* 16px */ box-shadow: 2.083px 2.777px 15.967px 0.69px rgba(193,197,210,0.0266); padding: 16px;}- The card shadow is deliberately subtle — it creates elevation without heaviness.
- In dark mode, the
--bg-surface(#1a1a1a) against--bg(#111111) provides the visual separation; the shadow is less critical but still applied. - Card inner padding:
16pxstandard. Use20px–24pxfor content-heavy cards (record cards, detail panels). - Never add a border to a card. If a card needs to show a selected/active state, use
outline: 2px solid var(--brand)— notborder.
7. Table Design
Section titled “7. Table Design”Tables always live inside a .tbl-card container. The table itself is borderless — separation is handled by --border-soft row dividers and the card shadow.
/* Table container */.tbl-card { background: var(--bg-surface); border-radius: var(--r-xl); box-shadow: 2.083px 2.777px 15.967px 0.69px rgba(193,197,210,0.0266); padding: 16px;}
table { width: 100%; border-collapse: separate; border-spacing: 0;}
/* Header row */thead tr { background: #f5f5f5; }[data-theme="dark"] thead tr { background: var(--bg-hover); }
th { font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: var(--text-2); padding: 0 16px; height: 48px; text-align: left; white-space: nowrap;}
/* Header pill corners */th:first-child { border-radius: 8px 0 0 8px; }th:last-child { border-radius: 0 8px 8px 0; }
/* Body rows */td { padding: 13px 16px; font-size: 13.5px; border-bottom: 1px solid var(--border-soft); vertical-align: middle;}tbody tr:last-child td { border-bottom: none; }tbody tr { transition: background .1s; cursor: default; }tbody tr:hover { background: var(--bg-hover); }Table header area (above the card)
Section titled “Table header area (above the card)”When a table has a title, search, or filter controls above it, those controls sit outside the .tbl-card — in a separate flex row — not inside the card. This keeps the card clean.
<!-- Controls row — outside the card --><div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;"> <div class="ftabs">...</div> <input type="search" class="search-box" placeholder="Search..."></div>
<!-- Card — no controls inside the header --><div class="tbl-card"> <table>...</table></div>Table rules
Section titled “Table rules”- No outer table border. No
border: 1px solidon the<table>or<tr>elements. - Row separation:
border-bottom: 1px solid var(--border-soft)on<td>, removed on last row. - Selected row:
border-left: 2.5px solid var(--brand)on<tr>(not on<td>). - Pagination, per-page selector, and goto-page go below the table, inside the
.tbl-card. - Even column distribution (HARD RULE).
.tblusestable-layout: fixed; width: 100%so columns fill the card exactly — no mid-row gap, no column hogging empty space, no wrapped cell content. The primary (first) column is widest (.tbl th:first-child { width: 34% }); the remaining columns split the rest evenly. Numeric/count/amount columns are right-aligned (.r). A table that bunches columns to one side with a gap, or lets one column hog blank space, is wrong.- When the first column is NOT the primary/name column — a leading rank#, chevron, or index — the 34% default would make that tiny column hog a third of the table and crush the data columns until their headers collide. Give such tables their own
<colgroup>(rank# ~4%, the name column widest, the rest even), and add the escape so the colgroup wins over the default:.tbl:has(> colgroup) th:first-child, .tbl:has(> colgroup) td:first-child { width: auto; }. (Both are already wired intemplates/web-flow-starter.html.) - Headers are
white-space: nowrap(single line) — if a header collides, shorten the label (terse is canonical:Covenant breaches (2-wk)→Breaches (2wk)) or widen that one column in the<colgroup>; never let it wrap and never drop to horizontal scroll. - A checkbox / index / action first column is narrow, not the 34% target. When the first column is a select-all checkbox (
~38px) or similar, thefirst-child { width: 34% }default mis-targets it (or is overridden and does nothing, leaving every content column equally cramped). Give the table a<colgroup>so the checkbox is~38pxand the name/entity column (2nd) gets the widest share.
- When the first column is NOT the primary/name column — a leading rank#, chevron, or index — the 34% default would make that tiny column hog a third of the table and crush the data columns until their headers collide. Give such tables their own
- No trailing caret / chevron column (HARD RULE). A clickable row (
.clickable+ a hover state) already signals it opens — do not add a trailing›/.row-caretcolumn. It is redundant and steals the right-side width that content needs. Detail still opens on row click. Enforced by theweb-table-row-caretlint. - Row actions = one small control only. If a row genuinely needs an inline actions affordance, it is the canonical circular borderless
.row-menu-btnwith a horizontal 3-dots (⋯) glyph — nothing else (no arrow, no stacked text buttons, no vertical dots). One line per row. - One line per cell — never wrap to 2–3 lines (HARD RULE).
.tbl tdiswhite-space: nowrap; overflow: hidden; text-overflow: ellipsis. A rider name, ticket label, station, or entity that wraps to two or three lines is wrong — it means the column is too narrow. Fix it by redistributing width (a<colgroup>that widens the long/entity columns using the space freed by removing the caret and keeping the checkbox/action columns narrow), or by ellipsis for genuinely long single values. Cells stay vertically compact (one text line + optional one sub-line via.td-sub), evenly aligned across the row.
8. Breakpoints & Grid
Section titled “8. Breakpoints & Grid”Mobile-first. Layer up with min-width breakpoints.
| Name | Token | Min-width | Columns | Margin | Gutter |
|---|---|---|---|---|---|
| Mobile | xs |
0px | 4 | 16px | 8px |
| Mobile L | sm |
480px | 4 | 20px | — |
| Tablet | md |
768px | 8 | 24px | 16px |
| Laptop | lg |
1024px | 12 | 32px | — |
| Desktop | xl |
1280px | 12 | 48px | 24px |
| Wide | 2xl |
1600px | 12 | 80px | — |
9. Component Inventory
Section titled “9. Component Inventory”All components exist in rechaj-design-system.html and rechaj-prototype/. Reference CSS class names below when building HTML/CSS.
Buttons
Section titled “Buttons”Mobile prototype pattern (preferred — used in swap & support cases prototypes):
.btn{ width:100%;height:52px;border:none;border-radius:14px; background:var(--brand);color:var(--grey12); font-family:inherit;font-size:15px;font-weight:600;letter-spacing:-0.2px; cursor:pointer; display:flex;align-items:center;justify-content:center;gap:8px; transition:transform 0.12s, box-shadow var(--transition);}.btn:hover{box-shadow:0 6px 18px rgba(0,217,100,0.30)}.btn:active{transform:scale(0.98)}.btn.disabled,.btn:disabled{ background:var(--grey4);color:var(--grey9);cursor:not-allowed;pointer-events:none;}.btn-secondary{ background:var(--white);color:var(--grey12);border:1px solid var(--grey4);}.btn.loading{background:var(--grey4);color:var(--grey9);cursor:not-allowed}DS class variants (web/desktop): .ds-btn-primary · .ds-btn-secondary · .ds-btn-tertiary-color · .ds-btn-tertiary-gray · .ds-btn-link-color · .ds-btn-link-gray
- Sizes:
.ds-btn-sm(36px) ·.ds-btn-md(40px, default) ·.ds-btn-lg(44px) ·.ds-btn-xl(48px) ·.ds-btn-2xl(60px) - States: default · hover · focused (
outline: 2px solid #01E46F; outline-offset: 2px) ·.disabled·.ds-btn-loading - Destructive: available variant — use Tomato red
- Rule: One primary button per view. Never two primaries side-by-side.
- Rule: No emojis in button labels — ever.
Canonical primary button color (HARD RULE — applies to ALL form factors):
The primary CTA is brand-green #01E46F background with dark #111111 text. Never white-on-green for utility buttons. White-on-green is reserved for promotional / marketing surfaces only.
.ds-btn-primary,.btn-primary { background: #01E46F; border-color: #01E46F; color: #111111;}.ds-btn-primary:hover,.btn-primary:hover { background: #00c45b; border-color: #00c45b; }.ds-btn-primary:active,.btn-primary:active { background: #00b351; border-color: #00b351; }.ds-btn-primary:disabled,.btn-primary:disabled { background: rgba(1,228,111,0.3); border-color: transparent; color: rgba(17,17,17,0.45); cursor: not-allowed;}Some older Atlas screens (e.g.
swap-pm-dashboard-v2.html) use the emerald#16a34aas their accent. That predates the components-library canon and should not be replicated on new flows. The brand-green color for buttons is#01E46Feverywhere.
Badges
Section titled “Badges”HARD RULES (apply to all form factors):
- Default size: 22px height · 12px / 500 · 6px radius · letter-spacing
-0.01em. - NO
text-transform: uppercase— badges render in natural sentence case (“Communication failure”, not “COMMUNICATION FAILURE”). - Tinted subtle variant by default — soft background + colored text + 1px tinted border. Solid variant is for marketing / promotional surfaces only.
.ds-badge { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 6px; font-size: 12px; font-weight: 500; font-family: 'General Sans', var(--font); letter-spacing: -0.01em; line-height: 1; white-space: nowrap; border: 1px solid transparent;}.ds-badge-sm { height: 18px; padding: 0 6px; font-size: 11px; border-radius: 5px; gap: 4px; }.ds-badge-lg { height: 26px; padding: 0 10px; font-size: 13px; border-radius: 7px; gap: 6px; }Color variants:
.ds-badge-brand(brand-green) ·.ds-badge-grey(neutral) ·.ds-badge-red(error) ·.ds-badge-orange(warning) ·.ds-badge-yellow·.ds-badge-blue(info) ·.ds-badge-purple·.ds-badge-pink
Style variants:
.ds-badge-subtle(default) — tinted bg + colored text + soft border. Used in tables, lists, and inline status..ds-badge-solid— full color bg + contrasting text. Reserved for prominent labels / marketing..ds-badge-outline— transparent bg + colored text + colored border. Quietest variant.
Dot variant: .ds-badge-dot adds a ::before 6×6 dot in the badge’s color. Use for live-state indicators (online / offline / in-progress).
Don’ts:
- ❌
text-transform: uppercase— never. Even for status badges. - ❌
letter-spacing > 0— keep negative or zero. - ❌ 5px radius — minimum is 6px (sm variant is 5px which is the floor).
- ❌ Hand-rolled
.badgestyles per page — always extend.ds-badge.
Inputs
Section titled “Inputs”- States: default · focus · error · disabled
- Component class:
.inp-*pattern - Focus ring:
border-color: var(--brand); box-shadow: 0 0 0 3px rgba(0,217,100,.12)
Toggles
Section titled “Toggles”- Class:
.tgl— standard iOS-style toggle - Common use: preference rows with title + optional description + trailing toggle
Checkboxes
Section titled “Checkboxes”- Class:
.cbx
Dropdowns
Section titled “Dropdowns”- Class:
.dd-input-*pattern
Avatars
Section titled “Avatars”- Class:
.av-variant-cardcontainer
List Picker
Section titled “List Picker”A single-select list of options. Use for assigning a rider to a workstation, picking a zone, choosing a shift, selecting a payment method — any “pick one from N” interaction.
HARD RULES:
- Use this pattern instead of raw cards-with-radio. The component already encodes the right hover, selected, and disabled states.
- Single-select only. For multi-select, use checkboxes inside cards instead.
- Disabled rows:
opacity: 0.45; pointer-events: none. Don’t hide unavailable options — show them dim so the user understands the full set.
Anatomy: optional left icon (36×36 rounded square) → label + sub-label → radio (right).
Markup:
<div class="lp-group" role="radiogroup"> <button class="lp-item is-selected" type="button" role="radio" aria-checked="true"> <div class="lp-icon"><!-- optional 18x18 SVG --></div> <div class="lp-content"> <div class="lp-label">Workstation 5</div> <div class="lp-sublabel">Lead: Ifeoluwa Olukoga · Idle</div> </div> <span class="lp-radio"><span class="lp-radio-dot"></span></span> </button> <button class="lp-item" type="button" role="radio" aria-checked="false" aria-disabled="true" disabled> <div class="lp-content"> <div class="lp-label">Workstation 1</div> <div class="lp-sublabel">Lead: Babajide Hassan · 1/1 active</div> </div> <span class="lp-radio"><span class="lp-radio-dot"></span></span> </button></div>Styles — verbatim:
.lp-group { display: flex; flex-direction: column; gap: 8px; }.lp-item { display: flex; align-items: center; gap: 14px; width: 100%; box-sizing: border-box; padding: 14px 16px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 10px; cursor: pointer; transition: border-color 0.15s, background 0.15s;}.lp-item:hover { border-color: #01e46f; background: #f9fffe; }.lp-item.is-selected { border-color: #01e46f; background: #f9fffe; }.lp-item[disabled], .lp-item[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
.lp-icon { width: 36px; height: 36px; border-radius: 10px; background: var(--bg-hover); display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--text-secondary);}.lp-item.is-selected .lp-icon { background: #dcfbe2; color: #05853e; }
.lp-content { flex: 1; min-width: 0; }.lp-label { font-size: 15px; font-weight: 600; color: var(--text-primary); line-height: 1.3; }.lp-sublabel { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
.lp-radio { width: 20px; height: 20px; border-radius: 50%; border: 1.5px solid var(--border); background: var(--bg-surface); display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: border-color 0.15s;}.lp-item.is-selected .lp-radio { border-color: #01e46f; }.lp-radio-dot { width: 10px; height: 10px; border-radius: 50%; background: #01e46f; opacity: 0; transform: scale(0.4); transition: opacity 0.15s, transform 0.15s;}.lp-item.is-selected .lp-radio-dot { opacity: 1; transform: scale(1); }JS — single-select toggle:
function pickItem(btn) { const group = btn.closest('.lp-group'); if (!group) return; group.querySelectorAll('.lp-item').forEach(b => { const active = b === btn; b.classList.toggle('is-selected', active); b.setAttribute('aria-checked', active ? 'true' : 'false'); });}Don’ts:
- ❌ Use
box-shadowrings instead ofborder-colorto indicate selection — the component reserves shadows for elevation, not state. - ❌ Skip the radio dot — it’s redundant with the border-color but required for accessibility.
- ❌ Hide the icon container per row when most rows have no icon — use the no-icon variant for the whole list (omit
.lp-iconfrom every row) so all labels align.
- Standard card:
.tbl-card—background: var(--bg-surface),border-radius: 16px,box-shadow: 2.083px 2.777px 15.967px 0.69px rgba(193,197,210,0.0266), no border - Item card:
.item-card— 40px icon, title, subtitle, chevron — same shadow treatment, no border,border-radius: 16px,min-height: 73px - All card variants (form card, content card, media card): shadow only, no border stroke
Status-hero card (used on detail screens — Pending review, Approved, Failed, etc.):
- Background stays white with the canonical card shadow. Tone goes only on a small circular tonal icon at the left (typically 36×36).
- NEVER apply a full-card tinted background. The previous “amber-tinted card” / “red-tinted card” patterns are deprecated. White card + small icon is the canonical look.
- Pair with the canonical 14.5px / 600 label + 12.5px muted sub line.
Empty state
Section titled “Empty state”Use the canonical .empty-wrap pattern whenever a list, table, or panel has zero matching items. Never use plain text-only “No items” copy.
Empty states render INSIDE a white container (HARD RULE). The .empty-wrap always sits in a white --surface card — a .card, or the .tbl-card whose table is empty — never bare on the grey page canvas and never inside a grey (--grey1) card. A bare empty state on the page background reads as a broken/unstyled screen; the white container gives it the same footing as every other block on the page.
<div class="card"> <div class="empty-wrap"> <div class="empty-ic"><svg …></svg></div> <div class="empty-t">No documents yet</div> <div class="empty-m">Short line on what will appear here once it exists.</div> </div></div>HARD RULES:
- Centered icon (48×48 circular grey tile,
var(--bg)background) + heading + sub - Heading: 14px / 600 /
var(--text-1) - Sub: 12.5px /
var(--text-2)/ max-width 320px / line-height 1.5 - Padding: 56px top/bottom, 24px sides
<div class="id-empty-state"> <span class="id-empty-state-ic"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> </span> <div class="id-empty-state-h">All caught up</div> <div class="id-empty-state-s">No items pending your review today.</div></div>.id-empty-state { display: flex; flex-direction: column; align-items: center; padding: 56px 24px; text-align: center; gap: 12px; }.id-empty-state-ic { width: 48px; height: 48px; border-radius: 50%; background: var(--bg); color: var(--text-3); display: flex; align-items: center; justify-content: center; }.id-empty-state-ic svg { width: 22px; height: 22px; }.id-empty-state-h { font-size: 14px; font-weight: 600; color: var(--text-1); }.id-empty-state-s { font-size: 12.5px; color: var(--text-2); max-width: 320px; line-height: 1.5; }Per-context messaging guidance — the empty state should narrate why it’s empty + what the user can expect next. E.g.:
- Approvals queue empty → “All caught up · You’ll see new requests here as they come in”
- Search no results → “No results · Try a different Serial or BMS ID”
- Quarantine no items → “No quarantine clears pending today · Procurement + QA route here when needed”
Alert Toast
Section titled “Alert Toast”The canonical toast / snackbar is a tinted pill with a leading white badge + text label. No icons, no emojis. Auto-dismiss after 4s; closeable via an inline × button.
HARD RULES:
- Tinted background only — never a dark surface, never a custom capsule with an icon circle.
- Leading badge (white pill inside the colored pill) is required. Format: short status word (“Saved”, “New”, “Warning”, “Error”, “Info”).
- Position: top-center. Web/desktop =
position: fixed. Mobile prototype =position: absoluteinside the.phoneshell (because.phonehasoverflow: hidden). - Auto-dismiss after 4s. Always include a close button so users can dismiss earlier.
Markup — the .alert-toast--<variant> class drives color:
<div class="alert-toast alert-toast--success" role="status" aria-live="polite"> <span class="alert-toast-badge">Saved</span> <span class="alert-toast-text">Today's slots updated</span> <button class="alert-toast-close" type="button" aria-label="Dismiss" onclick="hideToast()"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> <line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/> </svg> </button></div>Styles — verbatim:
.alert-toast { position: fixed; /* mobile prototype: position: absolute inside .phone */ top: 24px; left: 50%; /* mobile prototype: top: 56px (clears the status bar) */ transform: translate(-50%, -16px); z-index: 80; display: inline-flex; align-items: center; gap: 8px; border-radius: 999px; padding: 4px 12px 4px 4px; white-space: nowrap; box-shadow: 0 4px 20px rgba(0,0,0,0.12); opacity: 0; pointer-events: none; transition: opacity 0.25s ease, transform 0.25s ease;}.alert-toast.is-visible { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.alert-toast-badge { display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.85); border-radius: 12px; padding: 3px 10px; font-size: 13px; font-weight: 500; line-height: 20px; white-space: nowrap;}.alert-toast-text { font-size: 13px; font-weight: 500; line-height: 20px; white-space: nowrap; }.alert-toast-close { background: none; border: 0; cursor: pointer; padding: 0 2px; margin-left: 4px; display: flex; align-items: center; color: currentColor; opacity: 0.55;}.alert-toast-close:hover { opacity: 1; }.alert-toast-close svg { width: 12px; height: 12px; }
/* Variants — badge + text inherit `color` from the variant */.alert-toast--success { background: #dcfbe2; color: #05853e; }.alert-toast--info { background: #e6f4fe; color: #0d74ce; }.alert-toast--warning { background: #fff7c2; color: #ab6400; }.alert-toast--error { background: #ffebe8; color: #e32627; }.alert-toast--gray { background: #f0f0f0; color: #646464; }JS — single shared toast element (mount once, reuse), showToast(message, badge, variant):
const TOAST_VARIANTS = ['success', 'info', 'warning', 'error', 'gray'];let _toastTimer = null;function showToast(message, badge, variant) { const t = document.getElementById('toast'); if (!t) return; variant = TOAST_VARIANTS.indexOf(variant) >= 0 ? variant : 'success'; TOAST_VARIANTS.forEach(v => t.classList.remove('alert-toast--' + v)); t.classList.add('alert-toast--' + variant); if (badge !== undefined) t.querySelector('.alert-toast-badge').textContent = badge; if (message) t.querySelector('.alert-toast-text').textContent = message; t.classList.add('is-visible'); clearTimeout(_toastTimer); _toastTimer = setTimeout(hideToast, 4000);}function hideToast() { const t = document.getElementById('toast'); if (!t) return; t.classList.remove('is-visible'); clearTimeout(_toastTimer);}Variant guidance:
success— confirmations after a save / publish / completed action (default)info— neutral notice (“Connection restored”, “Schedule pulled”)warning— soft caveat the user should notice but not blockingerror— failures, retry-required statesgray— quiet/secondary info; rarely needed
Don’ts:
- ❌ Dark capsule with an icon circle (the wrong pattern; use the tinted pill)
- ❌ Bottom-of-screen toasts (we anchor top-center)
- ❌ Toasts without a close button
- ❌ Multiple toasts stacked (replace, don’t queue, unless the system explicitly needs a stack)
Navigation
Section titled “Navigation”Web/Desktop
- Sidebar:
.sb-*pattern (collapsible subnav)
Mobile App Header (.mhdr) — verbatim CSS:
.mhdr{background:var(--bg);box-sizing:border-box;flex-shrink:0}.mhdr-status{height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 24px}.mhdr-bar{height:56px;display:flex;align-items:center;justify-content:center;padding:0 20px;position:relative}.mhdr-back{ position:absolute;left:20px; width:40px;height:40px;border-radius:50%; border:1px solid var(--grey6);background:var(--white); display:flex;align-items:center;justify-content:center;cursor:pointer;}.mhdr-action{ position:absolute;right:20px; width:40px;height:40px;border-radius:50%; background:var(--grey3); display:flex;align-items:center;justify-content:center;cursor:pointer;}.mhdr-title{font-size:18px;font-weight:600;color:var(--grey12);letter-spacing:-0.3px;text-align:center}- Status bar (mhdr-status) is required at the top of every screen with
9:41time + battery/wifi icons - Back button is bordered white circle on the left
- Optional action button is grey-3 circle on the right
- Title is centered with
font-weight: 600(semibold)
Mobile Bottom Nav (.mnav) — only on root-level screens (dashboard, batteries, wallet, etc.). Never on sub-flows like forms, case detail, or sheets.
.mnav{ position:absolute;bottom:0;left:0;right:0; display:flex;background:var(--white); border-top:1px solid #ebebeb; padding-bottom:22px;z-index:10;}.mnav-tab{ flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center; padding:10px 4px 12px;gap:4px;cursor:pointer; border-top:2.5px solid transparent;}.mnav-tab.is-active{border-top-color:var(--brand)}.mnav-icon{width:24px;height:24px;color:var(--grey9)}.mnav-tab.is-active .mnav-icon{color:var(--g-dark)}.mnav-label{font-size:11px;font-weight:500;color:var(--grey9)}.mnav-tab.is-active .mnav-label{color:var(--g-dark)}Dialogs / Modals (Web — primary confirmation pattern)
Section titled “Dialogs / Modals (Web — primary confirmation pattern)”Web/desktop uses centered modals, NOT bottom sheets. Use the canonical .dlg widget documented in components/index.html (lines ~4260-4540, page #page-dialog). Mobile + tablet use bottom sheets — see the next subsection.
HARD RULES (web):
- Modal is centered on a dimmed backdrop (
rgba(0,0,0,0.5)) with bounce-in animation. - Default width 491px; wide variant 640px for forms with many fields.
- Header (top): 20px / 600 title + 14.5px / 400 muted description + 32px circular ✕ close at top-right.
- Body: 24px horizontal padding, scrollable when needed (
max-height: 60vh). - Footer (bottom): light strip + top border + horizontal action buttons — Cancel left, primary right. NEVER stack buttons vertically on web.
- ESC key closes. Click on backdrop closes (clicks inside the modal don’t bubble).
Canonical markup:
<div class="dlg-overlay" id="dlg-confirm"> <div class="dlg"> <div class="dlg-header"> <div class="dlg-title">Approve remap?</div> <div class="dlg-desc">This commits the new mapping live to all consumer systems. The old BMS ID will be permanently retired.</div> <button class="dlg-close" type="button" onclick="closeDlg('dlg-confirm')" aria-label="Close"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> </button> </div> <div class="dlg-body">… optional form fields …</div> <div class="dlg-footer"> <button class="ds-btn ds-btn-md ds-btn-secondary" onclick="closeDlg('dlg-confirm')">Cancel</button> <button class="ds-btn ds-btn-md ds-btn-primary" onclick="confirmApprove()">Approve remap</button> </div> </div></div>Required CSS scaffolding (from components/index.html — copy verbatim, do not re-derive):
.dlg-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; pointer-events: none; transition: opacity 0.2s;}.dlg-overlay.is-open { opacity: 1; pointer-events: all; }.dlg { background: var(--bg-surface); border-radius: 16px; width: 491px; max-width: 100%; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.12), 0 8px 24px rgba(0,0,0,0.08); transform: translateY(12px) scale(0.97); opacity: 0; transition: transform 0.22s cubic-bezier(.34,1.56,.64,1), opacity 0.2s;}.dlg-overlay.is-open .dlg { transform: translateY(0) scale(1); opacity: 1; }.dlg--wide { width: 640px; }.dlg-header { padding: 24px 24px 20px; display: flex; flex-direction: column; gap: 8px; position: relative; }.dlg-title { font-size: 20px; font-weight: 600; line-height: 30px; color: var(--text-1); padding-right: 44px; }.dlg-desc { font-size: 14.5px; font-weight: 400; line-height: 22px; color: var(--text-2); }.dlg-close { position: absolute; top: 20px; right: 20px; width: 32px; height: 32px; border-radius: 100px; background: var(--bg); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center;}.dlg-close:hover { background: var(--border); }.dlg-body { padding: 0 24px 20px; display: flex; flex-direction: column; gap: 16px; max-height: 60vh; overflow-y: auto; }.dlg-footer { background: var(--bg); border-top: 1px solid var(--border); padding: 18px 24px; display: flex; justify-content: flex-end; gap: 12px; }Don’ts (web):
- ❌ Bottom sheets on a web flow — that’s mobile/tablet only
- ❌ Centered alert with stacked buttons — buttons go horizontal on web
- ❌ Modal without an ✕ close button
- ❌ Background opacity outside
rgba(0,0,0,0.5)(we have one backdrop, don’t invent darker)
Bottom Sheet (Mobile) — primary modal pattern
Section titled “Bottom Sheet (Mobile) — primary modal pattern”Use bottom sheets for: pickers, search overlays, confirmation modals, success states, action menus, photo source pickers, media previews. NEVER use full-screen overlays for these.
.modal-bg{ position:absolute;inset:0; background:rgba(0,0,0,0.55); backdrop-filter:blur(2px); z-index:30;display:none;cursor:pointer;}.modal-bg.active{display:block;animation:fadeIn 0.22s ease both}
.sheet-wrap{position:absolute;bottom:0;left:0;right:0;z-index:40;display:none}.sheet-wrap.active{display:block}.sheet{ background:var(--white); border-radius:24px 24px 0 0; overflow:hidden; box-shadow:0 -8px 40px rgba(0,0,0,0.12); animation:slideUp 0.32s cubic-bezier(0.32,1.25,0.6,1) both; max-height:78vh; display:flex;flex-direction:column;}@keyframes slideUp{from{transform:translateY(100%)}to{transform:translateY(0)}}.sh-pill{width:40px;height:4px;background:var(--grey5);border-radius:99px;margin:10px auto 0}.sh-head{ padding:12px 20px 16px; display:flex;align-items:center;gap:12px; border-bottom:1px solid var(--grey4);}.sh-title{font-size:17px;font-weight:600;color:var(--grey12);letter-spacing:-0.2px}.sh-sub{font-size:12px;color:var(--grey10);margin-top:2px}.sh-close{ width:32px;height:32px;border-radius:50%;background:var(--grey3); display:flex;align-items:center;justify-content:center;cursor:pointer;border:none;}Sheet variants (composed inside .sheet):
- Search + results (rider search, etc.):
.sheet-search(input wrapper) →.sheet-results(scrollable list of.res-rowwith.res-av+.res-name+.res-meta) - List picker (single-select):
.lp-list→.lp-row(with.lp-radioon the right; toggle.selectedclass for picked state) - Action list (camera/gallery, photo source, etc.):
.sh-actions-list→.sh-action-rowwith.sh-action-ic(40px circular green tint) + text stack
NEVER use the older “dark surface bottom sheet” (rgba(17,17,17,0.7) + blur). The current pattern is white sheet + dark backdrop.
Success / Confirmation Modal — bottom sheet (DEFAULT, automatic)
Section titled “Success / Confirmation Modal — bottom sheet (DEFAULT, automatic)”HARD RULE — apply automatically, no need to ask: Every success state on a mobile flow uses the bottom sheet pattern below. This is the default for ANY successful action — form submit, confirmation, completed task, payment, signup, etc. NEVER use full-page overlays, centered modals, toast-only confirmations, or inline banners as the sole success affordance on mobile.
The user should never need to specify “use the success bottom sheet” — assume it.
Pattern (verbatim from swap & support prototypes):
.success-dimmed{position:absolute;inset:0;background:rgba(0,0,0,0.55);backdrop-filter:blur(3px);z-index:60;display:none}.success-dimmed.active{display:block;animation:fadeIn 0.22s ease both}.success-sheet-wrap{position:absolute;bottom:0;left:0;right:0;z-index:61;display:none}.success-sheet-wrap.active{display:block}.success-sheet{ background:var(--white);border-radius:28px 28px 0 0; padding:16px 24px 38px; animation:slideUp 0.4s cubic-bezier(0.32,1.25,0.6,1) both;}.success-check{ width:68px;height:68px;border-radius:50%;background:var(--brand); margin:10px auto 18px; display:flex;align-items:center;justify-content:center; animation:popBounce 0.55s 0.15s cubic-bezier(0.34,1.56,0.64,1) both; box-shadow:0 8px 28px rgba(0,217,100,0.4);}@keyframes popBounce{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.success-title{font-size:22px;font-weight:600;color:var(--grey12);text-align:center;letter-spacing:-0.4px}.success-desc{font-size:14px;color:var(--grey10);text-align:center;line-height:1.65}.success-id-pill{display:inline-flex;background:var(--grey3);border-radius:99px;padding:6px 12px;font-size:12.5px;font-weight:600;color:var(--grey11);font-family:'SF Mono',monospace}Structure: drag pill → animated green check (popBounce) → title (semibold 600, 22px) → description → optional ID pill → action stack (primary .btn + secondary .btn.btn-secondary).
Media Preview — bottom sheet
Section titled “Media Preview — bottom sheet”Photo / attachment preview also uses the bottom sheet pattern. Sheet head shows filename + size, body shows the photo at aspect-ratio: 3/4 with max-height: 520px. Tapping the close button or backdrop dismisses via closeAllSheets().
Read-only Evidence Sheet + Edit-submission affordance
Section titled “Read-only Evidence Sheet + Edit-submission affordance”Any completed in-app camera submission (SOP step, QA sub-check, retrofit checkpoint, MEI sub-step) opens as a view-only bottom sheet so reviewers / auditors can inspect without re-entering the capture path. Reference implementation lives in rechaj-kit-production-app/DESIGN.md and is reused 1:1 in the retrofit app.
Shell — #sheet-evidence with body containing: result chip (.ka-evi-result[data-state="done|pass|fail|pending"]), read-only photo thumbs (.ka-evi-thumbs), behavioural meta (.ka-evi-meta), and an optional note (.ka-evi-note with .ka-evi-note--defect for red text). CTA stack carries an Edit submission primary button + a Close secondary.
Edit-submission rules:
- Only visible when the parent record is still editable (e.g. not yet submitted to the next stage, not in a terminal
pass/defect/closedstate). The visibility gate is data-driven viaopenEvidence({ onEdit })— pass a function when editable,nullotherwise. - Tapping it closes the view-only sheet, sets an
editingIdxflag, and re-opens the capture screen pre-filled with the prior photos + note. Title swaps toEdit · <step name>; CTA copy swaps toSave changes. - Save path updates in place — never bump completion counters or append duplicate defect entries. Pass-after-defect must remove the stale defect record by
{ gate, subcheck }key. - Toast copy distinguishes update from first-time submission (
"<x> — submission updated"/"<x> — defect updated"). - Back-arrow / Cancel paths clear the
editingIdxflag so a subsequent fresh entry doesn’t inherit stale state.
Camera-only evidence — no infrastructure hints
Section titled “Camera-only evidence — no infrastructure hints”Any in-app camera capture screen across the design system must follow:
- In-app camera only — capture buttons open the mock viewfinder, never a file picker / gallery import.
- No infrastructure hints in the UI. Helper copy must NEVER surface platform invariants like
"EXIF + SHA-256 stamped on every shot","In-app camera only · no gallery import","capture stamped with workstation", etc. These are infrastructure the platform guarantees — they aren’t user-actionable rules and they bloat the capture UI. - Behavioural copy is fine. Captions that tell the user what to capture or when (
"Mandatory before pass or defect.","1 photo from Production QA","View only after submission.","Hold steady · frame the chassis VIN") are encouraged. - Audit grep:
grep -rn 'EXIF\|SHA-256\|In-app camera only' products/<product>/flows/should return zero hits.
If a hint mixes infra + behaviour (e.g. "Camera-only · EXIF + SHA-256 stamped on every shot · view only after submission."), strip only the infra fragment and keep the behavioural remainder.
Tables
Section titled “Tables”See Section 7 for full table spec.
- Header:
font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: .06em - Row:
font-size: 13.5px; padding: 13px 16px - Selected row:
border-left: 2.5px solid var(--brand) - Includes: pagination, per-page selector, goto-page input — all below the table inside
.tbl-card
App header (.mhdr) — top of any sub-screen
Section titled “App header (.mhdr) — top of any sub-screen”The canonical app header is a two-row stack: the iOS status row (44px, time + system icons) above the bar (56px, centered title flanked by absolute back + action). Background is transparent so the header blends with whatever surface the page uses (light-grey body, dark prototype backdrop, etc.) — the title shouldn’t sit on a white plate that breaks the page tone.
.mhdr { background: transparent; box-sizing: border-box; }.mhdr-status { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; }.mhdr-time { font-size: 15px; font-weight: 600; color: var(--grey12); }.mhdr-bar { height: 56px; display: flex; align-items: center; justify-content: center; padding: 0 20px; position: relative; }.mhdr-back { position: absolute; left: 20px; width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--grey6); background: var(--white); display: flex; align-items: center; justify-content: center; color: var(--grey12); cursor: pointer; }.mhdr-action { position: absolute; right: 20px; width: 40px; height: 40px; border-radius: 50%; background: var(--bg-hover); border: 0; display: flex; align-items: center; justify-content: center; color: var(--grey12); cursor: pointer; }.mhdr-title { font-size: 20px; font-weight: 600; color: var(--grey12); text-align: center; flex: 1; letter-spacing: -0.3px; }Hard rules.
- Title is always centered.
flex: 1; text-align: centeron.mhdr-title.- Back + action are
position: absoluteso the title never shifts depending on whether buttons are present.- Header background is transparent — never paint a separate white plate behind the bar; let the page surface flow through. Status row + bar together blend into the body’s background.
- Back button is outlined (white fill, 1px
--grey6border). Action button is soft-filled (--bg-hover/--grey2). They’re visually distinct so users instinctively read direction-of-travel.- Top-level tabs (Home/Queue/Inbox/Profile) omit the back button — the centered title alone is the affordance.
Tabs (Mobile Segmented)
Section titled “Tabs (Mobile Segmented)”.tab-bar{ background:var(--grey3);border-radius:12px; padding:4px;display:flex;}.tb-item{ flex:1;padding:8px 0;text-align:center; font-size:12.5px;font-weight:500;color:var(--grey11); border-radius:9px;cursor:pointer;}.tb-item.active{ background:var(--white);color:var(--g-dark);font-weight:600; box-shadow:var(--sh-xs);}.tb-count{display:inline-block;margin-left:4px;color:var(--grey10);font-weight:500}.tb-item.active .tb-count{color:var(--g-dark)}Active tab text uses brand green (
--g-dark/#05853e), never grey-12. This is a hard rule.
FAB (Floating Action Button)
Section titled “FAB (Floating Action Button)”Mobile prototypes use two FAB variants:
.fab{ position:absolute;border:none; background:var(--brand);color:var(--grey12); cursor:pointer;z-index:20; display:flex;align-items:center;justify-content:center; box-shadow:var(--sh-fab); transition:transform 0.12s, box-shadow var(--transition);}.fab.fab-circle{width:56px;height:56px;border-radius:50%;right:20px;bottom:96px}.fab.fab-extended{ height:52px;border-radius:99px;padding:0 20px; font-size:14px;font-weight:600;gap:8px; right:20px;bottom:24px;}.fab-badge{ position:absolute;top:-4px;right:-4px; min-width:20px;height:20px;border-radius:99px; background:var(--red9);color:#fff; font-size:10.5px;font-weight:700; display:flex;align-items:center;justify-content:center; border:2px solid var(--bg);padding:0 5px;}--sh-fab = 0 6px 24px rgba(0,217,100,0.45).
Stat Cards (KPI tiles)
Section titled “Stat Cards (KPI tiles)”When displaying multiple stat tiles in a mobile list view, use horizontal scroll with icon-left layout — never a 2x2 or 1x3 grid. The third card peeks in to indicate scrollability.
.stat-grid{ display:flex;gap:10px; padding:14px 16px 4px; overflow-x:auto; scroll-snap-type:x proximity; scroll-padding-left:16px; scroll-padding-right:16px; scroll-behavior:smooth;}.stat-grid::-webkit-scrollbar{display:none}.stat-card{ background:var(--white);border-radius:14px; padding:12px 14px;box-shadow:var(--sh-card); display:flex;align-items:center;gap:10px; min-width:150px;flex-shrink:0; scroll-snap-align:start;}.stat-card-ic{ width:40px;height:40px;border-radius:50%; display:flex;align-items:center;justify-content:center;flex-shrink:0;}.stat-card-text{display:flex;flex-direction:column;gap:2px}.stat-card-val{font-size:20px;font-weight:700;color:var(--grey12);letter-spacing:-0.4px}.stat-card-lbl{font-size:11.5px;color:var(--grey10);font-weight:500}Required behavior: when the user scrolls and stops, auto-reset scrollLeft to 0 after a 1.5s idle, so the first card always returns to its default position. Implement via JS scroll listener with debounced timeout.
Quick Action Cards (dashboard tiles)
Section titled “Quick Action Cards (dashboard tiles)”Used for primary dashboard actions (Transfer, Update battery, Push battery, etc). Two-column grid, fixed 73px height, line-clamp at 2 lines so cards stay equal height.
.qa-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.qa-item{ background:var(--white);border-radius:16px;padding:15px 18px; display:flex;align-items:center;gap:8px;box-shadow:var(--sh-card);cursor:pointer; min-height:73px;height:73px;}.qa-ic{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0}.qa-lbl{ font-size:14px;font-weight:500;color:var(--grey12);line-height:1.3; display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical; overflow:hidden;letter-spacing:-0.1px;}QA icon backgrounds use --g-2 (#f4fbf5), --amber2 (#fefbe9), --blue2 (#f4faff), --red2 (#fff7f6) — light tints, not the brighter step-3 colors.
Form Inputs (Mobile)
Section titled “Form Inputs (Mobile)”.input{ width:100%;height:50px; background:var(--white); border:1.5px solid var(--grey4);border-radius:12px; padding:0 16px; font-size:14px;font-family:inherit;color:var(--grey12); outline:none; display:flex;align-items:center;}.input.focused{border-color:var(--brand);box-shadow:0 0 0 4px rgba(0,217,100,0.12)}.input.locked{ background:var(--grey3);color:var(--grey11);cursor:not-allowed; display:flex;align-items:center;justify-content:space-between;}.input.amount-prefix .naira{ padding:0 12px 0 16px;font-size:14px;font-weight:600;color:var(--grey10); height:100%;display:flex;align-items:center;border-right:1.5px solid var(--grey4);}.input-trigger{cursor:pointer;justify-content:space-between}.textarea{ width:100%;min-height:80px; background:var(--white); border:1.5px solid var(--grey4);border-radius:12px; padding:13px 16px; font-size:14px;outline:none;resize:none;}Field group structure (always use .field-group wrapping .field-label + input):
<div class="field-group"> <div class="field-label">Rider name <span class="req">*</span></div> <div class="input input-trigger" onclick="openSheet(...)"> <span class="input-text-placeholder">Search registered riders</span> <span class="input-icon"><svg .../></span> </div></div>Required fields use <span class="req">*</span> (red asterisk). Optional fields use <span class="opt">(optional)</span> in grey.
Case / Item Card (List Row)
Section titled “Case / Item Card (List Row)”Used for any card that represents an entity in a list (case, swap record, rider). Three-section structure with a tinted footer strip:
.case-row{ background:var(--white);border-radius:14px;padding:0; box-shadow:var(--sh-card); display:flex;flex-direction:column; cursor:pointer;overflow:hidden;}.cr-top{padding:13px 14px;display:flex;align-items:center;gap:12px}.cr-divider{height:1px;background:var(--grey3);margin:0 14px}.cr-body{ padding:11px 14px; display:flex;align-items:center;justify-content:space-between;gap:12px;}.cr-status{ padding:10px 14px; display:flex;align-items:center;gap:8px; font-size:12.5px;font-weight:600;}.cr-status.open{background:var(--red2);color:var(--red9)}.cr-status.progress{background:var(--amber2);color:var(--amber11)}.cr-status.resolved{background:var(--g-2);color:var(--g-dark)}Layout pattern:
- Top section: 40px circular icon + title (semibold 600, 14.5px) + status badge on the right
- Subtle grey-3 divider (1px) with 14px horizontal margin (so it stops short of card edges)
- Body: split layout — primary identifier (rider name) with monospace ID below on the left; right-aligned label + time pill on the right (e.g. “SUBMITTED / 2 min ago”)
- Tinted status strip: full-width at the bottom, color-coded by state, with icon + status message
Don’t use a colored left border accent for status. The tinted footer strip carries the status color signal.
Detail Page — main card + activity timeline
Section titled “Detail Page — main card + activity timeline”For entity detail screens (case detail, rider detail), use this structure:
Main card (.cd-card):
.cd-card{ background:var(--white);border-radius:24px; box-shadow:var(--sh-card);overflow:hidden; display:flex;flex-direction:column; padding-bottom:16px; flex-shrink:0; /* critical — see Cards rules above */}.cd-card-head{padding:16px 16px 18px;display:flex;align-items:center;gap:12px}.cd-card-icon{width:48px;height:48px;border-radius:50%;...}.cd-card-title{font-size:16px;font-weight:600;letter-spacing:-0.2px}.cd-card-sub{font-size:12px;color:var(--grey10);font-family:'SF Mono',monospace}.cd-divider{height:1px;background:var(--grey4);margin:0 16px}.cd-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:16px}.cd-grid .cd-detail.full{grid-column:1/-1}.cd-detail-ic{width:32px;height:32px;border-radius:50%;background:var(--grey2)}.cd-detail-val{font-size:13.5px;font-weight:600;color:var(--grey12)}.cd-detail-lbl{font-size:11.5px;color:var(--grey10)}.cd-photo-card{ margin:0 16px 16px; border:1px solid var(--grey4);border-radius:12px;padding:12px; display:flex;align-items:center;gap:12px;}.cd-status-banner{ margin:0 16px 16px;border-radius:12px;padding:12px 14px; display:flex;align-items:center;gap:10px;font-size:12.5px;font-weight:500;}.cd-status-banner.resolved{background:var(--g-2);color:var(--g-dark)}.cd-status-banner.progress{background:var(--amber2);color:var(--amber11)}Order inside .cd-card: head (icon+title+id+badge) → divider → grid (2-col detail rows) → photo subcard → status banner.
Activity timeline (.cd-timeline-card) — separate card below the main card:
.cd-timeline-card{ background:var(--white);border-radius:24px; box-shadow:var(--sh-card);padding:16px; flex-shrink:0;}.cd-timeline-item{display:flex;align-items:flex-start;gap:14px;padding:8px 0;position:relative}.cd-timeline-item:not(:last-child)::before{ content:'';position:absolute; left:18px;top:42px;bottom:-8px; width:2px;background:var(--grey4);}.cd-timeline-dot{ width:36px;height:36px;border-radius:50%; display:flex;align-items:center;justify-content:center; border:2px solid var(--white);position:relative;z-index:1;}.cd-timeline-dot.created{background:var(--grey3);color:var(--grey11)}.cd-timeline-dot.assigned{background:var(--amber2);color:var(--amber11)}.cd-timeline-dot.comment{background:var(--blue2);color:var(--blue9)}.cd-timeline-dot.manager{background:var(--g-2);color:var(--g-dark)}.cd-timeline-dot.resolved{background:var(--brand);color:var(--grey12)}.cd-timeline-quote{ margin-top:8px;padding:10px 12px; background:var(--grey2);border-radius:10px;font-size:13px;line-height:1.45;}.cd-timeline-quote.agent{background:var(--blue2)}.cd-timeline-quote.manager{background:var(--g-2)}Each timeline item: filled circle “dot” (color-coded by event type) + content stack (title + timestamp + optional inline quote bubble for comments). The vertical line connector is drawn via the ::before pseudo-element.
Status timeline (state-machine progress)
Section titled “Status timeline (state-machine progress)”For lifecycle / state-machine progress lists (work-order steps, retrofit phases, multi-stage approvals, activity logs that mirror a known state graph), use this canonical timeline pattern. Distinct from .cd-timeline-* (which is for free-form entity activity feeds with comment quotes), this one expresses discrete, ordered states with three visual states: done, active, pending. The pattern is also the canonical “Activity log” treatment when activity events map to a state machine.
When to use
- Use it for: state machine progress (
Assigned → Accepted → Validated → Started → Finished → Sent to QA), checklist runs, multi-stage approvals, anything where each row is a known step with a fixed order. - Don’t use it for: dashboard feeds with mixed/free-form events (use
.cd-timeline-*), simple bullet lists, or step-by-step capture detail (those belong inside the relevant entity card, not the lifecycle log).
Anatomy
- 28×28 circular icon (
-step-ic) with the rail (-step-rail) running 2px thin from below it down to the next step. Rail isvar(--grey4)forpending,var(--g-primary)fordone. Last step hides its rail. - Card body (
-step-body) is white withvar(--sh-card)fordone/activerows;pendingrows render as transparent text-only (no card chrome) to keep the future steps visually quiet. doneicon: green tick onvar(--g-primary).activeicon: amber clock on#FEF3C7with a soft amber glow ring.pendingicon: hollow circle onvar(--grey3).- Body packs name + a single timeline of meta —
Time · ActororTime · Detail. Don’t add a third actor pill row; the canonical pattern is two lines max.
CSS
.tl-step { display: flex; gap: 12px; position: relative; padding-bottom: 16px;}.tl-step:last-child { padding-bottom: 0; }.tl-step-rail { position: absolute; left: 13px; top: 28px; bottom: -2px; width: 2px; background: var(--grey4);}.tl-step:last-child .tl-step-rail { display: none; }.tl-step[data-state="done"] .tl-step-rail { background: var(--g-primary); }.tl-step-ic { width: 28px; height: 28px; border-radius: 50%; background: var(--grey3); display: flex; align-items: center; justify-content: center; flex-shrink: 0; position: relative; z-index: 1; color: var(--grey10);}.tl-step[data-state="done"] .tl-step-ic { background: var(--g-primary); color: var(--text-main);}.tl-step[data-state="active"] .tl-step-ic { background: #FEF3C7; color: #B45309; box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.15);}.tl-step-ic svg { width: 14px; height: 14px; }.tl-step-body { flex: 1; min-width: 0; background: var(--white); border-radius: 12px; padding: 10px 12px; box-shadow: var(--sh-card);}.tl-step[data-state="pending"] .tl-step-body { background: transparent; box-shadow: none; padding: 6px 0 6px 4px;}.tl-step-name { font-size: 13px; font-weight: 600; color: var(--grey12); letter-spacing: -0.05em;}.tl-step[data-state="pending"] .tl-step-name { color: var(--grey10); }.tl-step-time { font-size: 11px; color: var(--grey10); margin-top: 2px;}Drop-in markup
<div class="tl-step" data-state="done"> <span class="tl-step-rail" aria-hidden="true"></span> <span class="tl-step-ic"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <polyline points="20 6 9 17 4 12"/> </svg> </span> <div class="tl-step-body"> <p class="tl-step-name">Chassis validated</p> <p class="tl-step-time">10:34 AM · VIN matched MEI record</p> </div></div>
<div class="tl-step" data-state="active"> <span class="tl-step-rail" aria-hidden="true"></span> <span class="tl-step-ic"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/> </svg> </span> <div class="tl-step-body"> <p class="tl-step-name">Auto-mech finished</p> <p class="tl-step-time">In progress · Mech · Muiz</p> </div></div>
<div class="tl-step" data-state="pending"> <span class="tl-step-rail" aria-hidden="true"></span> <span class="tl-step-ic"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"> <circle cx="12" cy="12" r="9"/> </svg> </span> <div class="tl-step-body"> <p class="tl-step-name">Retrofit complete · sent to QA</p> <p class="tl-step-time">Awaiting Lead</p> </div></div>Naming
The class prefix above (.tl-step-*) is the canonical generic name. Products may scope under their own prefix (e.g. retrofit-app uses .rsm-wo-step-*) but the structure, state set, and visual rules must match this spec exactly. When you see an existing .cd-timeline-* activity feed that is actually a state-machine progression, migrate it to this pattern.
Don’ts
- ❌ Don’t introduce extra states beyond
done/active/pending. Errors and remediations get their own follow-up step, not a fourth state on the same row. - ❌ Don’t add an actor “pill” as a third line in the body — keep it inline in the time line (
Time · Actor). - ❌ Don’t show step-by-step capture events (each photo, each checklist tick) in the lifecycle log. Those belong inside the relevant crew/entity detail screen. The lifecycle log only shows when a phase started and finished.
- ❌ Don’t use box-shadow rings as the rail; the rail is a positioned 2px element, not a stroke.
Mobile App Anatomy — rsm-* scaffold (canonical for all new mobile apps)
Section titled “Mobile App Anatomy — rsm-* scaffold (canonical for all new mobile apps)”Every internal-tool mobile app at Rechaj (Retrofit, Kit Production, future Inventory Mgmt, future Battery Identity, etc.) is built on the same rsm-* scaffolding. New apps must adopt this scaffold verbatim — don’t invent parallel scaffolding. The class names are intentionally generic (rsm- = Rechaj Standard Mobile) so they’re product-neutral.
A screen inside .phone (or .tablet) is always:
<div class="screen tab-screen" id="s-some-screen"> <div class="sb"><!-- iOS status bar (time + system icons) --></div> <header class="rsm-page-head"> <div class="rsm-page-head-row"> <button class="rsm-page-back" type="button" aria-label="Back" onclick="goTo('s-prev')"><!-- ‹ --></button> <h1 class="rsm-page-title">Page title</h1> </div> </header> <div class="rsm-body" style="padding-top: 4px;"> <!-- Section heads, cards, lists, CTAs — flexed column with gap: 18px --> </div> <nav class="rsm-nav" aria-label="Primary"><!-- 3-tab bottom nav --></nav> <div class="home-ind"></div></div>Critical rules for new apps:
.rsm-body { padding: 0 16px; display: flex; flex-direction: column; gap: 18px; }(mobile) orpadding: 0 24px(tablet). Body gap covers “different kinds” sibling spacing automatically — wrap same-kind groups (card lists, button stacks) inside a<div>with their owngap: 8px/gap: 16pxto opt out..rsm-page-backis a circular 40×40 button with 1px grey-6 border, white background, and a left-pointing chevron — never a plain text< Back. Sits absolutely positioned atleft: 20pxinside.rsm-page-head-row, which isposition: relativeso the title centers cleanly..rsm-page-titleis 20px / weight 600, centered, flex: 1, single line. Never 700; never bold. If the title is long enough to wrap behind the back button, shorten it — keep the back button absolute, don’t push the title down..sbstatus bar has two variants —.sb(light, dark icons on white) and.sb--dark(dark, white icons over a green/dark backdrop e.g. on success modals). The dark variant isposition: absolute; top: 0; z-index: 3;over the activated-sheet shell..home-indis a 5px-tall pill at the bottom of the device. Always present, even on full-screen modals — it’s the iOS home indicator simulation..rsm-navis a 3-tab fixed bottom nav (Home / Queue / Profile in retrofit + kit production). New apps may add a 4th tab (Inbox) but should not exceed 4. Each tab is a<button class="rsm-nav-tab">with SVG + label; the active tab carries.is-active.
Ten rules a new mobile flow must satisfy
Section titled “Ten rules a new mobile flow must satisfy”Every one of these is a defect that shipped in a finished flow and was found by
measuring the render, not by reading the markup. Each is enforced by lint-canon
(rule id in brackets) and is already satisfied by templates/mobile-flow-starter.html
— so a flow that starts from the template and stays lint-clean cannot regress them.
Existing flows are baselined per file: they warn at their current count and error the
moment a new one appears.
- One page colour on every screen —
.screen{ background:var(--page) }, and--pageis a real token, not a hex hard-coded in one rule. Never add a.tab-screen{ background }override: that split makes every pushed screen a white sheet and every tab screen the page, so a white card on a pushed screen has nothing to sit on.[mobile-page-colour-split] - A
[data-pane]is a slice of the body, not a new layout — it inherits the body’s column and gap through.rsm-body > [data-pane]:not([hidden]){ display:flex; flex-direction:column; gap:18px }. Keep the:not([hidden]):display:flexbeats thehiddenattribute and every pane renders at once without it. A treatment the markup has to opt into is a treatment half the panes will not carry.[mobile-pane-no-column] - A head belongs to what is under it — ~8px down to its content, the body’s
full 18px up to the section before. Pull it with
.rsm-body > .rsm-section-head{ margin-bottom:-10px }. At the full gap on both sides a head reads as floating between two sections rather than labelling one.[mobile-section-head-loose] - A tab strip hugs its tabs —
align-self:flex-start; width:fit-content; max-width:100%. Full-bleed leaves 100–200px of empty well on the right, which reads as a control that failed to fill itself. The one exception is a strip that sits inside a card and divides that card’s own content; scope that with its own class.[mobile-tabs-full-bleed] .km-tabsis the tab component — a view filter over one list is a tab strip, not a row of chips. Do not hand-roll a second one.- Every select raises the canonical list picker, never the OS wheel — the
<select>stays in the DOM,hidden, as the value every existing handler already reads; a button beside it opens the picker sheet, which writes back to the select and fireschange, so dependent chains keep working untouched.[mobile-native-select] - A sheet closes by backdrop or by its own Cancel — never a back chevron in the
handle row. It is a third control doing what the other two already do, and a
back affordance on something that never went forward.
[mobile-sheet-back-button] - What a sheet LISTS sits on a grey card — consequence lines, option rows,
record rows, file rows. The sheet is already the white surface a card would use, so
a white or bare row inside one is white on white. Scope it by where it sits
(
.rsm-sheet .<row>{ background:var(--grey2); box-shadow:none }), never by a modifier the markup opts into: the opt-in version is exactly how one sheet’s plain list quietly went without it.[mobile-sheet-item-not-grey],[sheet-tile-white] - The page scrolls whole — nothing parks between the header and
.rsm-bodyunless it is genuinely a fixed header. A block pinned there costs its full height on every swipe for the life of the screen. An element inside the body must not add its own 16px inset; the body already pads. An empty container is collapsed (.rsm-body > *:empty{ display:none }) so it neither books a gap slot nor paints an empty bar.[mobile-band-above-scroll] - One rule per selector, and tokens keep their own names — two rules with the
same selector let the later one silently win, and if they describe different
components that share a name both end up with the merged result. If a flow needs
a value the DS token does not hold, give it its own name (
--track,--sh-soft) rather than redeclaring--grey2/--sh-card.[duplicate-selector],[token-value-drift]
Five more, from the corrections made to teammates’ pushed designs
Section titled “Five more, from the corrections made to teammates’ pushed designs”Each of these was a fix applied by hand to a flow someone else had already pushed, so each could recur on the next one. Now enforced.
- A divider belongs BETWEEN two things — a
border-bottomon a row or a head draws under the LAST one too, leaving a hairline with nothing beneath it, which is what a card that is only a head looks like. Put the line on the container that only exists when there is something to divide (.rsm-q-card-rows{ border-top }), or use the adjacent-sibling form (.row + .row), or cancel it (:last-child{ border-bottom:none }).[dangling-bottom-stroke] - CTAs are not pills — the canonical button is the 8px-radius
.btn-primary/.btn-secondary. A fully rounded button is not a Rechaj CTA. Handles, icon containers, avatars, badges, chips, filter chips and segments are round on purpose and exempt.[noncanon-pill-cta] - A tab strip boots on its FIRST tab — any other default reads as state the
user left behind, and on a cold load there is none.
[tab-not-first-active] - A card’s elevation is a token —
var(--sh-card)on web,var(--sh-soft)on the mobile shells. A hand-written rgba triple is how two sibling flows in one product drift apart without either looking wrong on its own. If a surface genuinely needs a different elevation, give it its own token name.[card-shadow-drift] - A field control is WHITE, delineated by its 1px border — a grey fill
reads as DISABLED, and a field that IS disabled or read-only is supposed to
look that way, which is exactly why an enabled one must not. The mobile field
is the SAME
.fldatom the web canon uses (label + control, 42px, 1px border, 8px radius); before this the mobile starter defined no field at all, which is why every mobile flow invented one (.pol-field,.inp,.form-group).[mobile-noncanon-form-field]
Two more the starter itself was breaking, fixed in it:
- A page-level CTA is the LAST thing on the page, pinned to the bottom — not
sitting under the content with dead space below it.
.rsm-bodyis already a filling flex column, so.rsm-body > .btn-primary{ margin-top:auto }does it: on a short page the button drops to the bottom, and on a page whose content fills the viewport the auto margin collapses and the normal gap applies. One rule, both states, no sticky footer. - A block that sits ABOVE
.rsm-bodymust carry the body’s own rhythm. The body’s 18px inter-section gap does not apply above its first child, so.rsm-statsmeasured 0px to the “Active work” head — the KPI cards butting straight into it.margin-bottom:18pxon the block, not padding on the body, which would add space at the top of every screen.
And the last of them
Section titled “And the last of them”- A confirm surface asks ONE question — a primary, a way out, and at most one
destructive alternative. Four competing buttons means several decisions are
sharing one overlay.
[overlay-too-many-ctas] - Reset closes the overlay — resetting clears every control, so the surface
has nothing left to say.
[filter-reset-not-closing](scoped to a Reset/Clear that actually lives inside an overlay; a small inline “clear this date” chip has no modal to close.) - A page action lives in
.pg-actions, top right — a primary button floating as a direct child of the page body is an action with no home.[page-action-loose-in-body]· WEB ONLY, because on mobile the page CTA pins to the BOTTOM of.rsm-body; the two arrangements are opposites and applying this rule to mobile would fight that canon. - A control that says it is locked must be
disabled— otherwise it takes a click and the user finds out the hard way.[locked-control-not-disabled] - A chevron is a promise that something opens — no
onclick,href,<button>or<a>in the row means the affordance points at nothing.[chevron-without-target] - A card title fits one line — a title that wraps makes its card taller than
its peers, so a row of cards stops being a row. Counted from PAINTED line
boxes (
Range.getClientRectsgrouped by rounded top); the element’s own height cannot tell you, since a two-line title in a taller card still measures one box.flow-probecheck 12d. - Nothing scrolls sideways inside its container — the probe runs at the
canonical width, so a table that overflows there overflows for everyone.
flow-probecheck 12e.
Rules 18 and 19 are regression guards: the corpus satisfies both today, and each was proved against a synthetic bad case rather than trusted because it reported zero. A zero that has not been proved is a broken rule, not a clean corpus.
One was attempted and dropped: “the padding left and right same as the sections
down the page.” It needs a datum that separates a section inset by its own
margin from a card with interior padding, and at the DOM level both are the same
16px. Box edges failed the mobile starter (.rsm-body pads its children while
.rsm-stats margins itself — different boxes, same content edge); content edges
failed it eight ways (a hero, a nav and a scrolling body legitimately differ);
restricting to the body’s own sections still failed the mobile starter
(.rsm-profile-user) and the web starter (.tbl-card) by exactly that 16px,
because a card’s padding is not a section inset. A check two of four references
fail would put a false failure in front of every teammate. The three failed datums
are recorded in flow-probe.js at check 12c so the next attempt starts there.
Where a sheet’s CTA gap is enforced: in flow-probe, not the lint. “At least
24px between content and the modal CTA buttons” is composed of two declarations
in the canon (8px padding-bottom on .rsm-sheet-body + 16px padding-top on
.rsm-sheet-cta), so a rule reading either alone measures 16px and fails the
reference. Only the render knows the real distance.
Copy, on the same footing: no explanatory captions [mobile-caption-paragraph] —
a screen that has to explain itself in a grey paragraph has not been designed; keep
counts, state and provenance only, and put a confirm sheet’s consequences in check
rows, one fact each, rather than a paragraph under them. (The rule exempts auth
screens, where telling someone who cannot sign in how to get access does earn its
place, and anything reading as a count or an “N of M” disclosure.) No em dashes in UI copy, no emoji. Do not state the same fact twice on one
screen. A control that says it is locked must actually be disabled, and a chevron
belongs only where the tap opens another surface.
Verify by driving, not by reading. Every rule above was broken in a flow whose markup looked right. Open the flow, drive every screen and every sheet, and read the computed values — background, gap, width — before calling it done.
.rsm-section-head — section title + meta
Section titled “.rsm-section-head — section title + meta”Every list / panel in .rsm-body is preceded by a section head:
<div class="rsm-section-head"> <h2 class="rsm-section-title">Components</h2> <span class="rsm-section-meta">8 of 12 scanned</span></div>rsm-section-title = 15px / 600. rsm-section-meta = 12px / 500 / grey-11. The section head reads as a label for the content beneath — 8–12px down to the first card (use .rsm-body .rsm-section-head + * { margin-top: -10px; } to pull, since rsm-body’s gap: 18px is too wide for label→content).
.rsm-wo-summary — order/record summary card (dark banner)
Section titled “.rsm-wo-summary — order/record summary card (dark banner)”The canonical “WO context” card used on any detail screen (work order, inspection record, kit detail, retrofit record, etc.). Dark background with a faded green vehicle silhouette in the corner, an avatar + name/meta row, a 2-column grid of label/value cells, and optional rsm-wo-summary-bundle rows for hardware IDs (chassis VIN, kit-id, bundle serial).
<div class="rsm-wo-summary"> <span class="rsm-wo-summary-bg" aria-hidden="true"><svg>…vehicle silhouette…</svg></span> <div class="rsm-wo-summary-head"> <div class="rsm-wo-summary-avatar">EB</div> <div class="rsm-wo-summary-id"> <span class="rsm-wo-summary-name">Ebipade Taribo</span> <span class="rsm-wo-summary-meta">FST901GH</span> </div> <span class="rsm-wo-summary-pill">In progress</span> </div> <div class="rsm-wo-summary-grid"> <div class="rsm-wo-summary-cell"><span class="rsm-wo-summary-cell-label">Work order type</span><span class="rsm-wo-summary-wftype rsm-wo-summary-wftype--retrofit"><span class="rsm-wo-summary-wftype-dot"></span>Retrofit</span></div> <div class="rsm-wo-summary-cell"><span class="rsm-wo-summary-cell-label">WO number</span><span class="rsm-wo-summary-cell-value">WO-2407-1100</span></div> <!-- … --> <div class="rsm-wo-summary-cell" style="grid-column: span 2;"><span class="rsm-wo-summary-cell-label">Completed</span><span class="rsm-wo-summary-cell-value">Wed 14 May · 9:18 AM</span></div> </div> <div class="rsm-wo-summary-bundle"> <span class="rsm-wo-summary-bundle-ic"><svg>…</svg></span> <div class="rsm-wo-summary-bundle-text"><span class="rsm-wo-summary-bundle-label">Chassis (VIN)</span><span class="rsm-wo-summary-bundle-value">JSAHSA3429NASA0019912</span></div> </div></div>- Workflow-type chip (
rsm-wo-summary-wftype--*) marks the kind of work, not its state. Use brand-green for--retrofit, blue (#93C5FDonrgba(59,130,246,0.18)) for--inspection. These are separate from status chips (Section: Status chips — canonical 3-color rule below). rsm-wo-summary-bundleis the canonical row pattern for any hardware identifier (chassis VIN, kit-id, battery serial, bundle serial). Circular green icon + label + monospaced value.
.rsm-q-row — queue / task list row
Section titled “.rsm-q-row — queue / task list row”Used on every Queue / Tasks tab. Tappable button-row with status pill on the right:
<button class="rsm-q-row" type="button" onclick="openTask(id)"> <div class="rsm-q-card-head"> <div class="rsm-q-card-id-row"> <span class="rsm-q-card-icon" aria-hidden="true"><svg>…</svg></span> <div class="rsm-q-card-id-text"> <p class="rsm-q-card-id">WO-2407-1100</p> <p class="rsm-q-card-type">Retrofit</p> </div> <div class="rsm-q-card-meta"> <span class="rsm-q-card-time">9:42 AM</span> <span class="rsm-q-card-status rsm-q-card-status--in-progress">In progress</span> </div> </div> </div> <div class="rsm-q-card-rows"><!-- label/value rows --></div></button>Day groupings in the Done tab use .rsm-q-day-head above each day’s list (uppercase 12px / 600 grey-11) with a relative day label (“Today · Wed 14 May”, “Yesterday · Tue 13 May”).
.rsm-result-band — score + outcome band
Section titled “.rsm-result-band — score + outcome band”The canonical pattern for any “final result” hero: score on the left, pill on the right, optional outcome line below. Used by MEI inspection records (retrofit) and any future scored evaluation (QA composite, retrofit yield, etc.).
<div class="rsm-result-band" data-result="pass"> <div class="rsm-result-band-text"> <span class="rsm-result-band-label">Final MEI composite</span> <span class="rsm-result-band-score">3.85 <span style="font-size: 12px; color: rgba(255,255,255,0.55); font-weight: 500;">/ 5 · Band B</span></span> </div> <span class="rsm-result-band-pill">Proceed</span></div><p style="font-size: 11.5px; color: var(--grey10); margin: 6px 2px 0; line-height: 16px;">Band-specific outcome line.</p>data-result="pass" → dark green (var(--g-dark)). data-result="fail" → red (#B91C1C). data-result="conditional" → amber. Pill text is the band action (Proceed / Conditional / Escalate / Reject), never a generic Pass/Fail.
.rsm-stat — KPI tile
Section titled “.rsm-stat — KPI tile”Dashboard stats use .rsm-stat (one tile) or .rsm-hero (full-width hero card). The number is the focal point and is the only place 700 weight is allowed on the screen (other than the iOS clock).
<div class="rsm-stat"> <span class="rsm-stat-label">Completed today</span> <span class="rsm-stat-num">12 <span class="rsm-stat-num-of">/ 14</span></span></div>rsm-stat-num = 20px / 700. rsm-stat-num-of = 13px / 500 / grey-10 (the “of total” denominator). Stack tiles in .rsm-stat-strip { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } (mobile) or 3-col (tablet).
.rsm-profile-* — Profile screen anatomy (4 sections)
Section titled “.rsm-profile-* — Profile screen anatomy (4 sections)”Every persona’s Profile follows the same shape. Reuse verbatim — don’t invent variant layouts.
<button class="rsm-profile-user" type="button"> <div class="rsm-profile-avatar">EB</div> <div class="rsm-profile-text"> <p class="rsm-profile-name">Ebipade Taribo</p> <span class="rsm-profile-role">Auto-mech · Workstation 5</span> <p class="rsm-profile-meta">ebipade@rechaj.com</p> </div> <span class="rsm-profile-chev"><!-- › --></span></button>
<div class="rsm-profile-section"> <p class="rsm-profile-section-head">Workspace</p> <button class="rsm-profile-row" type="button"> <span class="rsm-profile-row-ic"><svg>…</svg></span> <span class="rsm-profile-row-label">Workstation<span class="rsm-profile-row-sub">Workstation 5</span></span> <span class="rsm-profile-row-tail"><!-- › --></span> </button> <!-- … --></div><!-- Sections (in this order): Workspace, Account, Preferences, Support -->Critical: .rsm-profile-user is a <button> element so it taps into Account. Browsers give buttons a default border that breaks the design — always include these resets:
.rsm-profile-user { border: 0; appearance: none; -webkit-appearance: none; background: var(--white); }The role pill (.rsm-profile-role) uses brand-green tint. Row sub-lines (.rsm-profile-row-sub) carry the secondary metadata (workstation number, email, language code) at 12px / grey-10 underneath the row label. No Inbox on internal-tool apps — notifications surface as callouts inside the relevant work surface, or as toasts.
.rsm-sheet-overlay — bottom-sheet shell (cross-link)
Section titled “.rsm-sheet-overlay — bottom-sheet shell (cross-link)”See Bottom Sheet (Mobile) — primary modal pattern above. The rsm-sheet-* classes are the canonical mobile-app implementation:
.rsm-sheet-overlay— full-device backdrop wrapper, toggled via.is-open.rsm-sheet-backdrop— onclick closer.rsm-sheet— the white sheet card sliding up from the bottom.rsm-sheet-handle-row+.rsm-sheet-handle— drag-pill at top.rsm-sheet-head— title + sub.rsm-sheet-body— body content.rsm-sheet-cta— CTA stack (alwaysdisplay: flex; flex-direction: column; gap: 10px; padding: 20px 0 24px;).rsm-sheet-home— home indicator pill at sheet bottom
Status chips — canonical 3-color rule (HARD RULE)
Section titled “Status chips — canonical 3-color rule (HARD RULE)”Status chips (WO state, task state, evidence state) across every persona and every product use exactly three colors:
| State | Background | Text | Use for |
|---|---|---|---|
| Pending | #FEF3C7 |
#B45309 |
Awaiting kit, Awaiting evidence, Pending, Booked |
| In progress | #DBEAFE |
#1E40AF |
In progress, With QA, currently being worked on |
| Completed | var(--g-3) |
var(--g-dark) |
QA passed, MEI passed, Done, Completed |
Use a 4th red variant (#FEE2E2 / #B91C1C) only for failure outcomes (MEI failed, QA failed, defect logged).
Do not use an “Active” status — collapse to “In progress”. Do not create new variants (“Pending review”, “Almost done”) — pick the closest of the three.
Canonical class names — apply on .rsm-q-card-status, .rsm-wo-card-status, .rsm-wo-detail-status:
<span class="rsm-q-card-status rsm-q-card-status--pending">Awaiting kit</span><span class="rsm-q-card-status rsm-q-card-status--in-progress">In progress</span><span class="rsm-q-card-status rsm-q-card-status--completed">QA passed</span><span class="rsm-q-card-status rsm-q-card-status--fail">MEI failed</span>Note: this rule does NOT apply to workflow-type chips (
Retrofit/Inspection/Auto-mech/MEI) which identify the kind of work, not its state. Workflow-type chips use brand-green and inspection-blue tints — see.rsm-wo-summary-wftypein the App Anatomy section.
Class-name prefix ownership (HARD RULE)
Section titled “Class-name prefix ownership (HARD RULE)”To prevent CSS drift as we add products, every component class has exactly one owner prefix. New product apps must declare their prefix(es) at the top of their product DESIGN.md and stick to them. Existing prefixes:
| Prefix | Owner | Use for |
|---|---|---|
rsm-* |
shared mobile scaffold (this section) | .phone-shell apps — page-head, body, nav, sheet, profile, wo-summary, queue rows, result band, stat tiles |
km-* |
Kit Production Manager (tablet) | Tabs, hero card, panel containers, serial rows, SOP rows, gate dots, WO pill, KPI strip |
pm-* |
Procurement Manager (tablet — inventory app) | Receiving session cards, quarantine rows, replenishment alert rows, mitigation tiles, KPI strip variant |
im-* |
Inventory Manager (tablet — inventory app) | Bundle request cards, push monitor, store views (kits + parts), serial assignment, returns, battery custody read-out |
rt-* |
Shared evidence capture (retrofit + kit production) | Active card, photo capture row, slot tiles, capture hint |
ka-* |
Kit Assembler + Production QA evidence | Camera viewfinder, scan/check/defect cards, kit-id label preview, read-only evidence sheet |
fg-* |
Forge — production floor app (mobile, all 3 personas) | Scan gate (fg-scan-*), serial chips (fg-serial-chip), checklist tiles (fg-check-tile) |
tl-* |
Team Lead (legacy — kept for back-compat) | Status timeline rows in the Team Lead WO log |
mhdr-* |
Web/desktop app header | The hero header pattern on web flows |
lp-* |
List Picker (cross-product) | Single-select picker rows — see Section 9 → List Picker |
Don’t:
- Add a new prefix without registering it here. If your component is genuinely product-specific (
assembler-only), use the product-owned prefix (ka-*in this case). If it’s general, lift it torsm-*and update this table. - Mix prefixes inside a single component (e.g. a
km-rowwithrt-thumbchild) unless one is clearly a cross-product import — and even then, document it in the product DESIGN.md.
Activated-sheet Success Modal — full-screen confirmation (DEFAULT for major transitions)
Section titled “Activated-sheet Success Modal — full-screen confirmation (DEFAULT for major transitions)”For major state-transition success confirmations (account activated, password reset, WO sent to QA, kit handed off to Inventory, invalid chassis escalated to RSM, etc.), use the full-screen activated-sheet shell instead of a bottom sheet. The bottom-sheet Success / Confirmation Modal pattern is the default for most successes — activated-sheet is reserved for completed actions where the user moves on and can’t undo (vs. inline confirmations like “Saved”).
Shell — full-screen <div class="screen"> with green-gradient backdrop:
<div class="screen" id="s-some-success"> <div class="activated-bg" aria-hidden="true"></div> <div class="sb sb--dark" style="position:absolute;top:0;left:0;right:0;z-index:3;"> <!-- status bar, dark variant --> </div> <div class="activated-sheet"> <div class="activated-pill"></div> <div class="activated-ic"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <polyline points="20 6 9 17 4 12"/> </svg> </div> <h2 class="activated-title">Escalated to RSM</h2> <p class="activated-sub">The invalid-chassis WO has been routed to the RSM for review.</p> <button class="btn-primary btn-primary--lg" style="width:100%;" type="button" onclick="closeSuccess()">Done</button> </div> <div class="home-ind"></div></div>Rules:
- Register the screen ID in your
SCREENSarray sogoTo()routes to it. - The Done CTA must clear any local state (inputs, in-flight photos, draft data) before navigating back to the queue / home tab — never leave stale state behind a completed action.
.activated-titleis the only place outside KPI digits and the iOS clock where 700 weight is permitted (it’s display-weight).
Don’t:
- Use a bottom sheet for a completed major action — bottom sheets imply “this dialog can be dismissed and the page is still alive”. Full-screen activated-sheet says “you finished, here’s what’s next”.
- Skip the
home-ind— it’s part of the device frame, not the modal. - Use a generic
OKbutton — say what happens next (Donereturning to queue;Back to home;View record).
Inline error sheet — bottom-sheet for recoverable failures
Section titled “Inline error sheet — bottom-sheet for recoverable failures”For recoverable input errors (invalid input that the user can re-enter, mismatch they can verify, validation that the user can correct), use a bottom sheet with two CTAs — one primary forward path + one secondary retry.
<div class="rsm-sheet-overlay" id="sheet-some-invalid"> <div class="rsm-sheet-backdrop" onclick="closeInvalid()"></div> <div class="rsm-sheet" role="dialog" aria-modal="true" aria-labelledby="invalid-title"> <div class="rsm-sheet-handle-row"><div class="rsm-sheet-handle"></div></div> <button type="button" class="rsm-sheet-close" onclick="closeInvalid()" aria-label="Close"><!-- × --></button> <div class="rsm-sheet-head"> <h2 class="rsm-sheet-title" style="color: #B91C1C;">Invalid chassis number</h2> <p class="rsm-sheet-sub">The chassis number you entered doesn't match any MEI record on this WO. Re-check the stamp, or escalate to the RSM for review.</p> </div> <div class="rsm-sheet-body"> <div class="rsm-status-banner" style="background: var(--red3);"> <span class="rsm-status-banner-ic" style="background: rgba(227, 38, 39, 0.14); color: var(--red9);"><!-- ⚠ --></span> <div class="rsm-status-banner-text"> <strong>Typed: 12345</strong> <small>No matching MEI chassis on this WO.</small> </div> </div> </div> <div class="rsm-sheet-cta" style="display: flex; flex-direction: column; gap: 10px; padding: 20px 0 24px;"> <button class="btn-primary btn-primary--lg" type="button" onclick="escalate()">Escalate to RSM</button> <button class="btn-secondary btn-secondary--lg" type="button" onclick="reEnter()">Re-enter chassis number</button> </div> <div class="rsm-sheet-home"><div class="rsm-sheet-home-pill"></div></div> </div></div>Rules:
- Title in red (
color: #B91C1C;) — the only place red coloring sits on a title. - Red status banner (
var(--red3)bg) echoing the typed value + a short, plain-English reason. Usersm-status-banner(already documented in the retrofit app’s CSS — generalizable). - Primary CTA = the forward path (“Escalate to RSM”, “Send for review”); secondary = the retry path (“Re-enter chassis number”, “Try a different plate”).
gap: 10pxbetween CTAs;padding: 20px 0 24pxon the CTA stack so the red banner has breathing room.- Primary forward CTA opens an activated-sheet success modal once committed. Secondary retry closes the sheet, clears the relevant input + state, and re-focuses the input.
Starter checklist for a new app
Section titled “Starter checklist for a new app”When kicking off a new internal-tool mobile/tablet app, the minimum viable scaffold is:
- Device shell — wrap every screen in
<div class="phone">(mobile, 390×844) or<div class="tablet">(tablet, 834×1194). Status bar (.sb), home indicator (.home-ind), and bottom nav (.rsm-nav) are present on every screen. - Page head — every sub-screen has a
.rsm-page-headwith a circular.rsm-page-backand a centered.rsm-page-title(20px / 600). Title shortens to fit; don’t wrap. - Body — scrollable
.rsm-bodywithpadding: 0 16px(mobile) /0 24px(tablet),display: flex; flex-direction: column; gap: 18px. - Section heads — every list / panel is preceded by
.rsm-section-headwith a 15px title + 12px meta. - Status chips — only use the canonical 3 (Pending amber / In progress blue / Completed green) + 1 failure red. Never invent a 5th.
- Cards — no strokes, ever, including single-side accents. Tinted background +
box-shadow: var(--sh-card)+border-radius: 14px. - Font weights — body 400/500; titles/IDs/names/buttons 600; 700 only for KPI digits, splash/success display titles, iOS clock. Never bold a name, WO number, or section title.
- Bottom sheets for confirmations, pickers, photo previews, error recoverables. Full-screen
activated-sheetfor major-transition successes. Centered alerts are forbidden. - Toasts for save confirmations (
showToast(message, badge, variant)). - Profile screen — 4 sections (Workspace · Account · Preferences · Support) with role pill +
.rsm-profile-userbutton resets. No Inbox unless explicitly needed. - Class-name prefix — declare it at the top of your product DESIGN.md and register it in the Class-name prefix ownership table.
- Camera-only evidence — never gallery import; never surface EXIF/SHA infrastructure hints in helper copy.
If your app exposes scored evaluations (anything resembling MEI / QA composite / yield score), use .rsm-result-band with the X.XX / 5 · Band X format and a band-action pill (Proceed / Conditional / Escalate / Reject) — not a generic Pass / Fail.
Other Components
Section titled “Other Components”- Tooltips: dark default (single-line) · rich variant (title + body, max-width 220px)
- Progress Indicators, Sliders, Steppers, OTP Input, Pagination — all documented in
rechaj-design-system.html - Charts: documented chart components
10. Design Rules (Do / Don’t)
Section titled “10. Design Rules (Do / Don’t)”Emojis
- NEVER use emojis in the UI under any circumstances
Iconography
- ALL icon containers are circular (
border-radius: 50%) — never rounded squares - Light tint backgrounds (
--g-2,--amber2,--red2,--blue2,--pink2,--grey2) — not the brighter step-3 colors - Stroke icons only, never filled — stroke-width 2 (or 2.5 for checks/x)
Colors
- Use
--brand(#01E46F) for all primary interactive elements - Use semantic color scales (Green/Blue/Red/Yellow) for status states
- Use brand green at 12–20% opacity for hover/selected backgrounds
- Active tab text uses
--g-dark(brand green) — never grey-12 - Never place green text on white or light backgrounds
- Never use Yellow as body text
- Never use Purple for status — only classification
Page Background
- Always use
background: var(--bg)on the page canvas — never--bg-surfaceonbody - Light page bg is
#fbfbfd(off-white), not pure white. In mobile prototypes, the phone shell uses#F5F5F5for off-white separation. - Cards (
--bg-surface=#fffffflight /#1a1a1adark) float on top of this
Cards
- No border/stroke on cards — shadow only
- Card shadow:
2.08px 2.78px 16px 0px rgba(193,197,210,0.10)(mobile prototype) or2.083px 2.777px 15.967px 0.69px rgba(193,197,210,0.0266)(desktop tbl-card variant) - Never stack two shadow tokens on one element
- Never use shadows decoratively
- Don’t use colored left borders for status — use a tinted footer strip or status badge instead
- When a card is a flex column child of a scrolling container, set
flex-shrink: 0so it can’t be squeezed by siblings
Modals & Sheets
- Use bottom sheets for pickers, success states, media previews, action menus — never full-screen overlays
- Success states are bottom sheets with the animated
.success-checkpattern + 2-button stack (primary + secondary)
Tables
- Always wrap tables in
.tbl-card - No outer table border — row dividers only (
border-bottom: 1px solid var(--border-soft)) - Header row: pill-radius corners on first/last
<th> - Filter/search controls go outside the card, above it
Typography
- Always use General Sans — no other typeface
- Default body = 14px / 1.5 line-height
- Titles, headings, and section labels use semibold (600) — never bold (700)
- Bold (700) is only for: avatar initials, status bar time, fab badges (where small-text legibility matters)
- Numerical values (stat counters, KPI numbers, wallet amounts) can use 700/800
Spacing
- All values must be multiples of 4px
- Never use values like 7px, 11px, 15px, 13px, etc. (Exception: well-known DS values like 13.5px table row, 15.5px card title — keep these.)
Buttons
- One primary button per view
- Use
.btn(52px height, 14px radius, brand bg) for mobile prototypes - Icons: 16px for md/lg, 14px for sm
- Never pair two primary buttons on the same surface
Layout
- Always use the column grid — never position content relative to viewport directly
- Mobile-first — design for xs, layer up
- Web canvas: 1440px — Mobile canvas: 390px
11. Figma MCP Usage Instructions
Section titled “11. Figma MCP Usage Instructions”When the user requests a flow that may already exist or be based on an existing screen:
- Use the Figma MCP to fetch only the specific frame(s) relevant to that screen by node ID
- Do NOT fetch the entire Figma file — this wastes tokens
- Use the existing screen as a guide for layout patterns and component placement
- Apply all tokens and rules from this file — the Figma screens are the layout reference, this file is the source of truth for tokens
Known existing prototypes (use as in-context reference for component composition):
- KaaG Dashboard screens →
screens/KaaG-Dashboard-Screens.html - Agent Onboarding Flow →
screens/Rechaj-Agent-Onboarding-Flow.html - Swap Prototype →
screens/Rechaj-Swap-Prototype.htmlandrechaj-prototype/index.html(canonical mobile reference — dashboard, FAB, scanner, success bottom sheet) - Support Cases Flow →
screens/support-cases.html(canonical reference for: case list with tabs, detail card + activity timeline, all 7 form types, list picker, rider search sheet, media preview sheet, success modal) - Onboarding Portal →
screens/rechaj-onboarding-portal.html
All NEW generated screens go in
screens/(seeCLAUDE.md§ “Where to put generated UI”). Don’t add new files torechaj-prototype/— that folder is reserved for the canonical swap prototype.
When asked to modify an existing flow, fetch the Figma frame for that screen first, then apply changes using tokens from this file. When building a new flow, grep these prototypes for the closest pattern and reuse the exact CSS classes.
12. Transition & Animation
Section titled “12. Transition & Animation”--transition: 0.18s ease /* Default for all interactive state changes */- Background color changes:
transition: background var(--transition) - Border color changes:
transition: border-color var(--transition) - Color changes:
transition: color var(--transition) - Transform interactions:
transition: transform 0.15s, box-shadow 0.2s - Collapse/expand (subnav, accordions):
max-heighttransition,0.22s ease