Pro Exteriors design system v1.2: the rules
This is the one design authority for every Pro Exteriors work surface: CRM, Command Center, HR, PM, Admin and the APP portal, on desktop and phone. It replaces @proexteriors/design-system 0.x, the CC /design-system site, Design.md, standards/design/v1.md, docs/40 and every app-local token file.
Chris set these terms on 2026-11-04: - Tie-breaker: where the current designs disagree, CC/CRM win. - Scope: every app surface except the marketing site. - Neutral ramp: cool gray only.
How the rules are worded: - MUST and NEVER are hard: a PR that breaks one does not merge. - SHOULD needs a written reason in the PR.
Rule ids are permanent. A rule is retired with a note and never renumbered. The CC and CRM rule ids each rule came from are kept in brackets, so old PRs and docs still resolve.
Live reference: https://ds.proexteriorsus.net. Package: packages/ds (@proexteriors/ds). Check: node packages/ds/scripts/lint.mjs <app dir>.
Conflicts settled (behaviour audit, 2026-11-04)
The audit of CC, CRM, HR and PM found ten places where the old rules disagreed. Each one is settled by the rule named, using the CC/CRM tie-breaker:
- Phone breakpoint: 760, with 1100 and a 320 floor. S-01.
- Touch targets: 48px on phones. S-08.
- Tables on a phone: card rows. CC's Friday report is the one named exception. T-10.
- Search text in the address: never. F-11.
- History:
replaceStatefor filters. F-12. - Long-list memory and pane height: saved and measured, with no fixed cut-offs. T-01, T-02, F-06.
- Office order: money trees by money, work trees by open count, queues by needs-review; "No office" last. N-08.
- Vendor level: required on CC money trees. N-01.
- Counts under filters: recomputed, with money denominators fixed to scope. F-03.
- Corners and pills: pills for status only; controls are 8px. L-01.
Every place where today's code breaks one of these is migration debt in the lint baselines, and the reapply PRs fix it.
K: Tokens
- K-01 MUST: Every colour, font, size, space, radius, border, shadow, duration and z-index an app renders comes from
dist/tokens.css(--pe-*). No literal hex,rgb(),hsl()or px colour/size values in app CSS or markup. Layout arithmetic such ascalc(var(--pe-space-4) * 2)is fine. [DSN-010, DSN-014] - K-02 MUST: Components use role tokens (
--pe-text,--pe-action,--pe-structure…), not ramp steps (--pe-gray-700). Ramp steps are for the token file and data-viz only. - K-03 NEVER: An app defines its own custom properties for design values. A value needed twice is added to
tokens/source.jsonin a ds PR. [DSN-014] - K-04 MUST: Component classes are prefixed
pe-. Old prefixes (pwa-,app-shell-,fw-,cfx-,hr-design classes,--color-*,--pwa-*) are migration debt and must not grow. - K-05 MUST:
dist/is generated byscripts/build.py. The build fails when a permitted text pair drops below 4.5:1 or a focus ring or control border drops below 3:1 in either mode.
C: Colour roles (five, never swapped) [DSN-011]
- C-01 Navy
--pe-structure: structure. The rail, top-level nest bars, the secondary high-emphasis button. - C-02 Flag red
--pe-action: the one primary action. Never decoration, never a heading, never a chart colour exceptviz-highlight. - C-03 Gold
--pe-wayfinding: wayfinding on navy only. The active-item marker and rail icons. NEVER a button, NEVER on white. - C-04 Green
--pe-good: status only. - C-05 Blue
--pe-link/--pe-info/--pe-focus: links, information and the focus ring. - C-06 NEVER: Any other accent: no teal, purple, violet or brand-alternate buttons. CC WIP/AR teal and the CRM
promotepurple are retired. - C-07 MUST: One neutral ramp, cool gray. Page
--pe-page(gray-50), cards--pe-surface(white), sunken--pe-surface-sunken(gray-100), borders gray-200/300. - C-08 MUST: Dark mode is per surface. Put
.pe-darkordata-theme="dark"on the surface root. Every component reads the same role tokens in both modes. [CC 2026-06-17]
Y: Type
- Y-01 MUST: Inter only, self-hosted, with weights 400/500/600/700/800 actually loaded. NEVER a weight that isn't loaded, so no faux bold (the h2 at weight 900 is the defect this fixes).
- Y-02 MUST: Body text is 14px, inputs 16px on phones. Scale: 12 · 13 · 14 · 16 · 18 · 20 · 24 · 30. Nothing in between.
- Y-03 MUST: Every number that lines up in a column or changes in place uses tabular figures (
.pe-num). - Y-04 NEVER: Monospace, except for codes a person copies (ids, job numbers on a Property Card). [DSN-013]
L: Layout and shape
- L-01 MUST: Three radii only:
control8px for buttons, inputs and filter chips;card12px for panels and nodes;pillfor status, tags and counts only.
A button or filter chip is never a pill. This keeps CRM NEST-6 for controls and containers, and CC's status pills; HR's 999px buttons and the 4px tag radius are retired. [CRM NEST-6; CC status pills]
- L-02 MUST: Borders separate things, not shadows. The only shadow is --pe-elevation-overlay, on dialogs, sheets and toasts. [Design.md, CMP-12]
- L-03 MUST: Spacing uses the 4px scale (--pe-space-*). Siblings are laid out with flex or grid gap, not margins.
- L-04 MUST: Nothing scrolls the page sideways at any width down to the 320px floor. Only a table wrapper or the org-chart canvas scrolls horizontally. [CC 2026-06-28, M-05, S-06; CRM A7, COLLECT-9]
S: Shell (the CRM AppShell is the reference)
- S-01 MUST: Every app uses the one shell: rail, header, main, and on phones a tab bar. Breakpoints:
- ≥1101px: a 240px navy rail.
- 761–1100px: a 72px icon rail, with the full navigation as an overlay and the logo in the header.
- ≤760px: the header has the logo, and a tab bar of at most five destinations is docked at the bottom. Filter chips move into a bottom sheet.
- 320px is the floor: every page works there.
This ladder (CRM docs/pwa/03) replaces CC S-07 (1280/1180/1100/820), HR's 900 and PM's 1180/820/600. Wherever an old rule says ≤820px (CC M-04, A-08), read ≤760px. [CRM ALPHA-14, PWA layout; CC S-07, M-01–M-03]
- S-02 MUST: The logo is the full Pro Exteriors logo (assets/pro-exteriors-logo.svg):
- In the rail it sits on a white chip, up to 208px wide, and links home.
- In the header it is 32px tall at tablet and phone widths.
- NEVER a "PE" monogram tile, a roof icon or a different mark. [CC TM-1]
- S-03 MUST: The active nav item is the deep navy band with a 3px gold inset marker. Navigation groups by department, then by workflow within it. One group is open at a time, and the active group opens by longest-prefix match. [CC nav]
- S-04 MUST: Header order is eyebrow, then h1, then actions in this order: Search, Help, Report a bug, Me. There are no breadcrumbs; the eyebrow and h1 carry the hierarchy. [CC nav]
- S-05 MUST: Search is not a destination. It opens the app's main list with its search box focused. [CRM SEARCH-INTO-CUSTOMERS-3]
- S-06 MUST: A retired route is a 301 stub naming its successor. It is never deleted outright, and never a 404. [CC docs/91]
- S-07 MUST: Planned nav items render badged as planned and never lead to a 404.
- S-08 MUST: Touch targets are 48px on phones, and the primary action is 56px. Desktop controls are 40px, or 32px in a dense table row. The 44px in CC A-06 and in today's code is migration debt. [CRM UX-SPEC, SHARED-DESIGN; supersedes CC A-06, PM-DS-42]
- S-09 MUST: A skip link is the first focusable element, and the focus ring is visible on every control (A-02).
- S-10 MUST: Every app's sign-in screen uses the one sign-in layout (.pe-signin). Only the app name, the headline and one sentence change between apps.
- The page is navy. A white card holds the full logo (S-02), the app name as the eyebrow ("CRM", "HR", "Command Center"), an h1 and one sentence.
- There is one flag-red primary, "Sign in to
B: Buttons and actions
- B-01 MUST: At most one flag-red primary per view. A second high-emphasis action uses
.pe-btn--structure(navy). [DSN-012] - B-02 MUST: A button label states exactly what happens ("Mark sent", "Approve 6 lines"). Afterwards a toast says what happened ("Marked sent").
- B-03 MUST: Every stateful action sits behind a confirm whose text states the scope, the effect and what it does not do, and names every recipient. Example: "This does not email anything. It records that you sent the email…" [CC docs/100]
- B-04 MUST: Nothing leaves the building without a human. "Mark sent" records a send and never sends. Customer messages need an explicit Send. [AGENTS.md]
- B-05 MUST: An action the server would refuse stays where it is:
- It shows at 50% opacity, stays focusable, and does nothing when clicked.
- Its reason is on one line directly under it (
.pe-reason, linked witharia-describedby). - NEVER hidden. [CRM MY-JOBS-NEST-6]
- B-06 MUST: No data means the control is greyed with a tooltip, not removed. [CC docs/88]
- B-07 MUST: Edits that recalculate locally say "recalculated locally, not saved". Inline saves flash
goodwhen persisted anddangerwhen rejected. [CC docs/40 §5] - B-08 MUST: Agent and human actions are told apart with a badge (the agent's name). [CC docs/59]
- B-09 MUST: A toolbar holds filters and at most two action buttons. Anything more goes in an overflow menu. The toolbar wraps and never scrolls. [CC CMP-05]
- B-10 MUST: Scripts read state from
data-*hooks, never from.checkedor the DOM's look. [CC CMP-11]
P: Pills, KPIs and status
- P-01 MUST: A status is a
.pe-statuswith a dot, a word and a colour. It never relies on colour alone. - P-02 MUST: Status colours mean good, warning, danger, info and neutral only.
- P-03 MUST: A KPI pill is a work queue: a count and one action. Numbers that never change what the person does next go in subtext. [CC kpi-pills, Chris 2026-08-09]
- P-04 NEVER: A KPI box that neither leads to a better view nor tells you something you'd act on. [Chris 2026-06-17]
F: Filters
- F-01 MUST: "All" (value
"") means no constraint on that dimension. It is never a synthetic aggregate row. The office default is "All PE offices". [CC invoice-audit] - F-02 MUST: Filters compose as AND across dimensions in one pass (
ds.js). One toolbar holds them, with a Clear control whenever any filter is set. [CC UX-03, invoice-audit-tree.ts:826; PM-DS-63] - F-03 MUST: Counts and rollups follow these rules under filters:
- Bar counts are recomputed for the rows the filters leave visible. When a filter is narrowing, the count reads "3 of 12".
- Money rollups and progress denominators follow the active scope (F-04), not the drill filters. Search and tolerance narrow what you see; they never restate the denominator.
- This settles the split between Invoice Audit (recomputed) and WIP, Credit Memos and Order Audit (fixed at load): those three must change. [CC invoice-audit-tree.ts:794-859]
- F-04 MUST: One scope control (
.pe-seg, for example To audit · Due now · All) answers one question. Never several checkboxes for one question. [CC docs/100] - F-05 MUST: Secondary filters (date range, tolerance) sit behind a "More filters" disclosure. [CC docs/100]
- F-06 MUST: Filter, reveal, "Show all" and open state persist per surface in
localStorage. Open ids are capped at 400. - A deep link (
?office=,?branch=,?rep=,?stage=,?trade=) wins over the stored state. The page lands already filtered and scrolled to the target. - A deep link is shown in the toolbar but never saved over the person's stored filters.
[CC UX-04, UX-05, CMP-10, docs/40 §6; CRM NEST doc §1, PEC-899]
- F-07 MUST: A deep-link value that isn't in the tree scopes the search box and shows the honest empty result. It never renders the page unfiltered. [CC docs/82]
- F-08 MUST: Any aggregate shown to a person is scoped to one office. Office, vendor and branch are silos the UI never crosses. [CC CONVENTIONS §10b]
- F-09 MUST: Filtering shows and hides server-rendered rows when the set fits one read. Past that, filters go to the server and the bars stay closed with a count. [CC credit-memos; CRM SEARCH-INTO-CUSTOMERS-1/2]
- F-10 MUST: Finished work (stale close-outs) is off the working board by default and reachable only through its own pill. [CC friday-wip]
- F-11 NEVER: Search text in the address or in storage. Structured filters (office, rep, stage, date) may go in the address; the words a person typed never do. HR and PM must change. [CRM SEARCH-INTO-CUSTOMERS-5]
- F-12 MUST: A filter change updates the address with history.replaceState. Only opening a record or a new page pushes history, so Back leaves the list rather than undoing filters one at a time. PM must change. [CRM NestSurface; HR]
- F-13 MUST: Search input is debounced at 250ms. While a read is running, the old answer stays on screen dimmed, with "Updating…" and aria-busy. Filters run on the server before counting and paging when the set doesn't fit one read (F-09). [CRM NEST-2, WORKSPACE-ORDER]
N: Nesting (one tree per domain, learned once)
- N-01 MUST: Lists that have an owner are nested trees (
.pe-nodeindetails/summary, one chevron per level), not flat tables. The canonical trees: - Accounting (CC): PE Office › Vendor Branch › Invoice › Line. Invoice Audit, Credit Memos and Order Audit use it, with Order replacing Invoice in Order Audit. Agreement Builder adds › Category › Item › Variation.
- Sales/CRM: Office › Sales rep › Trade (or Job type) › Estimate/Job. Customers, Properties and Jobs use it; Staff is Office › Role group › Person.
- People (HR): Office › Department › Person.
- PM: Office › Crew › Job.
Every tree starts with Office, then the owner: the vendor branch for money, the rep for sales, the crew for production. The vendor level is required on every CC money tree; Invoice Audit, Order Audit and Credit Memos currently skip it and must add it. A new tree is added here before it ships. [CC UX-01, CMP-06; CRM NEST-1, MY-JOBS-NEST]
- N-02 MUST: The level-0 bar is navy (data-level="0"), and deeper levels are white with a border. The indent is --pe-size-nest-indent per level.
- N-03 MUST: The open state works like this:
- The top level is open and deeper levels are closed. A tree with two or fewer top-level nodes opens one more level.
- Every tree has Expand all and Collapse all. Expand all is guarded (disabled, with its reason, B-05) until an office or rep is chosen whenever it would cause an unbounded read.
- A saved Expand all is not reapplied while the guard holds.
[CC docs/40 §5; CRM NEST-2, nest-model openStateFor]
- N-04 MUST: A bar shows its name, then its count, then its rollup tags (money first), then its progress bar. Money rolls up from the deepest line.
- N-05 MUST: A parent hides when all its children are hidden. [CC docs/40 §5]
- N-06 MUST: Filtering or searching opens every branch that holds a hit, up to 100 hits. Past 100, the bars stay closed and show their counts. [CC UX-12, credit-memos; CRM SEARCH-INTO-CUSTOMERS-1]
- N-07 MUST: Controls inside a summary (links, inputs, the next-task link) never toggle the node. [CC friday-wip; CRM MY-JOBS-NEST-5]
- N-08 MUST: The default sort follows what the tree is for. It is never alphabetical by default.
- Money trees (CC): offices and branches by amount at risk, descending; documents by date, then at risk; lines by absolute variance; categories by their stored sort order. [CC D-07, invoice-audit.ts]
- Work trees (CRM, HR, PM): offices and owners by open item count, descending. [CRM NEST-1]
- Review queues: items needing review come first, then the money or count order. [CRM COLLECT-9]
- Wherever it appears, "No office" (or no owner) sorts last.
- N-09 MUST: A progress bar shows at every level, rolled up from the deepest line. There are two leaf modes: disposition (from the database) and review (from localStorage). [CC progress-checklist]
- N-10 MUST: Line lists group into collapsible category sections, collapsed by default. "Uncategorized" is shown, never hidden. [CC Chris 2026-06-19]
- N-11 MUST: Re-rendering keeps which nodes and sections are open. [CC invoice-audit-tree.ts:361]
- N-12 MUST: A record that legitimately belongs to two parents is listed under both. That is not a dedupe bug. [CC builder]
- N-13 MUST: Each surface makes one read. No per-row fan-out; the first 50 are batched and the rest load on "Show all". [CRM NEST-8/9]
- N-14 MUST: Every bar says why it is yours on "My …" views. [CRM MY-JOBS-NEST-1–4]
T: Tables and long lists (viewport behaviour included)
Table rules continue the T series from T-01 onward. T-01 to T-05 are the token rules above.
- T-01 MUST: An unbounded list opens at 10 rows. The rest sit behind "Show all N
▾ (M more)" / "Show first 10 only ▴". The choice is saved per surface (F-06). NEVER cut a list off at a fixed number (the PM Inbox at 20) without that control. Past 25 on a home card, link to the full paged list instead. [CC standards/v1 long-list, UX-06; CRM HOME-WORK; PM-DS-65] - T-02 MUST: The pane is the same height collapsed and expanded, so the mouse wheel has one owner. The height is measured (header + 10 rows + scrollbar gutter, in two passes) and never hardcoded (
ds.js). [CC friday-wip, S-09] - T-03 MUST: Filters apply before paging: the ten shown are the first ten matches.
- T-04 MUST: A wrapper that scrolls uses
overflow: autoon both axes. NEVERoverflow-x: autowithoverflow-y: visible. [CC four sticky-header faults, 2026-08-21] - T-05 MUST: [CC CMP-04]
- Sticky headers use
border-collapse: separate. - Zebra and hover are painted on
td, so frozen cells stay opaque. - Multi-row header offsets are measured.
- The first column freezes on wide tables (
.pe-freeze). - T-06 MUST: Numbers are right-aligned in tabular figures, and labels are left-aligned. Alignment is never mixed within a group.
- T-07 NEVER: A table 15 columns wide. Group before widening: headline columns become pills in the summary row, and the rest go in a labelled detail grid. [CC UX-07, docs/40 §5]
- T-08 MUST: Every group shows the same columns in the same order. Column-group expand state is global, not per office, so every table on the page stays the same width. [CC D-04, friday-wip]
- T-09 MUST: Cell text that would make the row taller is shortened in the cell and given in full in
title. A column name wider than its numbers is abbreviated, with the full name in the tooltip. [CC friday-wip] - T-10 MUST, by viewport:
- ≥1101px: compact rows (32px) with a sticky header, and frozen first column where wide.
- 761–1100px: the same table; its wrapper scrolls horizontally inside the panel.
- ≤760px: every table becomes one card per row (
.pe-table--stack). Each card has a headline cell first, then label and value pairs, with 48px touch targets. Nest bars wrap their tags onto a second line. A required value is never hidden to make a card fit. - The one exception is a report a person reads as a spreadsheet (CC Friday WIP, cash flow). It keeps its table, scrolls inside its own wrapper, and freezes the identity column (
.pe-freeze). It is listed here by name; a new one needs a ds PR.
This settles card rows (CRM UX-SPEC, CC-PARITY) against the frozen column (CC M-04 Strategy A) in favour of cards, and keeps CC's original Friday report as it is (AGENTS.md). [CC M-04; CRM UX-SPEC UX-061, CC-PARITY:14] - T-11 MUST: List loaders page their reads. NEVER trust an unpaged select: it caps at 1000 rows silently. [CC D-10, docs/40 §8]
E: Empty, loading, error
- E-00 MUST: There is one set of states: loading, empty (worded differently when filters are set), error with Retry, denied, and signed out. [CC CMP-07; HR-WS-18]
- E-01 MUST: Every filtered list has an empty state that names the way out ("No invoices match. Clear the tolerance filter."). NEVER sample or fake rows. [CC docs/90]
- E-02 MUST: Loading shows where the content will be. NEVER a blank page.
- E-03 MUST: An error says what failed and what to do next, without blame.
- E-04 MUST: Every surface that holds files shows:
- a placeholder icon when it is empty;
- drag and drop;
- the device's own tools in one click;
- a camera option alongside Photo library.
[CC UX-11]
A: Accessibility (WCAG 2.1 AA, checked in CI)
- A-01 MUST: Every page passes axe (WCAG 2.1 A and AA) in CI with zero violations.
- A-02 MUST: The focus ring is 2px
--pe-focuswith a 2px offset (gold on navy). It is never removed. - A-03 MUST: Links in running text are underlined. Colour alone never marks a link (the hr
link-in-text-blockfailure). - A-04 MUST: Text contrast is 4.5:1, and large text, icons and control borders are 3:1. These are enforced by the token build, so use role tokens only.
- A-05 MUST:
prefers-reduced-motionturns off all transitions. - A-06 MUST: Status and errors never rely on colour alone (P-01).
M: Motion
- M-01: Durations are 150ms for state changes and 200ms for panels. The easing is
--pe-motion-ease. No decorative motion.
G: Governance
- G-01 MUST: A UI PR names the ds rule ids it relies on and carries the SR-27 screenshots: each changed screen at the frame's width, plus phone (390×844) and tablet (768×1024) shots, in both themes where the surface has dark mode. [CC M-09, G-02]
- G-02 MUST:
node packages/ds/scripts/lint.mjs <app>passes in CI for every app. The baseline file lists only existing debt and may only shrink. - G-03 MUST:
- A change to a token, component or rule lands in
packages/dswith its site page, in the same PR. - Each release bumps the version and is published to ds.proexteriorsus.net and the Claude design-system library entry.
- G-04 MUST: A new pattern needs a ds PR first. Apps never invent a local one.