Force UI Design System — Design Philosophy & Decision Framework
Layer 2 of the Force UI spec. This document defines the design language, decision rules, and token mapping for the Perforce Force UI design system. It is optimized for both human and AI consumption. For token source of truth, see the token JSON files. For component API docs, see Storybook.
1. System Identity
Force UI is the enterprise design system for Perforce products. It targets data-intensive, professional applications where users spend hours per day. The visual language is flat, minimal, and information-dense.
Core values:
- Clarity — Every visual element communicates something. Remove anything decorative that does not carry meaning.
- Efficiency — Reduce cognitive distance between user intent and result. Tighter spacing, smaller default text, more information visible without scrolling.
- Professional density — Default to compact layouts. This is not a marketing site. Users are power users.
- Flat and structural — Borders define regions. Shadows communicate elevation (floating layers), not importance.
Brand identifiers:
| Property | Value |
|---|---|
| Primary brand color | Indigo #5405ff (--force-color-bg-primary) |
| Secondary brand color | Cyan #00cfff (--force-color-bg-secondary) |
| Primary font | Noto Sans (fallback: -apple-system, Segoe UI, Roboto, sans-serif) |
| Mono font | Noto Sans Mono (fallback: SF Mono, Monaco, Cascadia Code, Consolas) |
| Default body text size | sm = 14px — NOT 16px |
| Default theme | Light. Dark mode overrides color and shadow tokens only. |
2. Design Principles
These are decision-making rules. When two implementations are both technically valid, apply these in order.
P1 — Explicit over computed. States (hover, active, focus, disabled) have dedicated tokens. Do not compute states with calc(), filter: brightness(), or OKLCH manipulation. Use --force-color-bg-primary-hover instead of darkening --force-color-bg-primary at runtime.
P2 — Density over whitespace. Default spacing is tight by design. Use --force-spacing-6 (24px) for card padding, --force-spacing-4 (16px) for grid gaps. Do not add whitespace to "breathe" — only add it when it resolves an actual cognitive problem.
P3 — Borders define structure; shadows signal elevation. Cards, panels, inputs, and table cells use border: 1px solid var(--force-color-border-default). Shadows are exclusively for floating/detached elements: modals, dropdowns, tooltips, popovers, toasts. Never apply a shadow where a border is the correct structural signal.
P4 — Semantic colors communicate status, not decoration. Success (green), warning (orange), error/cranberry, and info (blue) exist to communicate system state. Do not use them to make UI visually interesting. The primary brand indigo is used sparingly — for interactive elements and emphasis only, not for painting large surfaces.
P5 — Three-level background hierarchy is fixed. surface (white) > muted (near-white with subtle blue cast) > emphasis (light grey for chrome). Do not introduce ad hoc background values. Do not reverse the hierarchy.
P6 — Typography hierarchy through size and weight only. Size and weight are the primary signals. Color is secondary reinforcement. Never rely on color alone to establish visual hierarchy in type.
P7 — Token-only styling. Every spacing value, color, radius, shadow, z-index, and duration in the UI must reference a --force-* custom property. No raw hex values. No arbitrary pixel values for spacing. No magic numbers for z-index.
P8 — Explicitly style every form element. Native form elements (<input>, <select>, <textarea>, <button>) inherit browser and host-page default styles that vary by OS, browser, and color scheme. Never rely on these defaults. Every form element MUST have explicit background, color, and border declarations using Force UI tokens. Without these, form controls will fall back to browser dark-mode defaults when the host page uses prefers-color-scheme: dark or a dark stylesheet, producing dark inputs on a light Force UI surface. Set appearance: none (with vendor prefixes as needed) on <input> and <select> elements to remove browser-native chrome, then apply all visual styling through tokens.
2a. Interpretation Modes
The spec supports two interpretation modes for AI agents consuming it via the MCP server. The mode is chosen by the agent based on the user's prompt and passed to MCP tools via the mode parameter.
Strict mode (default)
Treat the spec as a specification. Patterns in patterns/ are prescriptive — follow anatomy, variants, states, and guidance as written. Tokens are required. Principles and accessibility requirements are binding. Deviate only when the spec is silent.
Use when the user is implementing a real component, page, or feature. Keywords: "build", "implement", "add", "fix", "ship", "wire up".
Creative mode
Treat the spec as a foundation for exploration. The goal is unique, well-considered ideas — not replication of existing components.
Required (non-negotiable) in both modes:
- Tokens: all colors, spacing, radius, typography, motion, and shadow values must come from tokens.
- Accessibility: focus rings, contrast, keyboard support, ARIA requirements remain binding.
- Principles: P1–P8 above still apply.
Optional (advisory) in creative mode:
- Component anatomy, variants, and states in
patterns/— deviate when a different structure serves the idea better. - Composition and layout guidance — try unconventional arrangements, novel visual hierarchy.
When deviating from a pattern in creative mode, briefly note what changed and why.
Use when the user is exploring. Keywords: "ideate", "explore", "brainstorm", "try", "what if", "unique", "novel", "prototype an idea", "mock up options".
Summary
| Aspect | Strict | Creative |
|---|---|---|
| Tokens | Required | Required |
| Principles (P1–P8) | Binding | Binding |
| Accessibility | Binding | Binding |
| Pattern anatomy | Prescriptive | Advisory |
| Pattern variants/states | Prescriptive | Advisory |
| Composition/layout | Prescriptive | Advisory |
3. Color System
Architecture
Tokens follow a strict two-tier model:
primitives.tokens.json → semantic-light.tokens.json / semantic-dark.tokens.json
(hidden, never shipped) (shipped as --force-* CSS custom properties)
Primitives (color.neutral.200, color.indigo.500, etc.) are aliasing sources only. They never appear in CSS. Semantic tokens are the only public API.
Background Hierarchy
| Role | Token | CSS Property | Value |
|---|---|---|---|
| Primary content surface | color.bg.surface |
--force-color-bg-surface |
#ffffff |
| Secondary / table headers / disabled inputs | color.bg.muted |
--force-color-bg-muted |
#f8f8fd |
| Navigation chrome (topbar, sidebar) | color.bg.emphasis |
--force-color-bg-emphasis |
#f3f3f8 |
| Inverted surfaces (tooltips) | color.bg.inverse |
--force-color-bg-inverse |
#111115 |
| Modal scrim | color.bg.overlay |
--force-color-bg-overlay |
rgba(0,0,0,0.5) |
Note: bg.muted (#f8f8fd) has a subtle cool/blue cast. This is intentional — it is the Perforce neutral palette signature.
Interactive State Backgrounds
| State | Token | Notes |
|---|---|---|
| Hover | color.bg.interactive-hover |
--force-color-bg-interactive-hover |
| Selected / Active | color.bg.interactive-active |
--force-color-bg-interactive-active — indigo-50 |
| Primary CTA default | color.bg.primary |
--force-color-bg-primary — indigo-500 |
| Primary CTA hover | color.bg.primary-hover |
--force-color-bg-primary-hover — indigo-600 |
| Primary CTA active | color.bg.primary-active |
--force-color-bg-primary-active — indigo-700 |
| Subtle primary tint | color.bg.primary-subtle |
--force-color-bg-primary-subtle — indigo-50 |
| Destructive CTA default | color.bg.destructive |
--force-color-bg-destructive — cranberry-500 |
| Destructive CTA hover | color.bg.destructive-hover |
--force-color-bg-destructive-hover |
| Destructive CTA active | color.bg.destructive-active |
--force-color-bg-destructive-active |
Semantic Status Backgrounds (alerts, badges, callouts)
| Variant | Background token | Text token | Border/accent token |
|---|---|---|---|
| Success | color.bg.success-subtle |
color.text.success |
color.border.success |
| Warning | color.bg.warning-subtle |
color.text.warning |
color.border.warning |
| Error | color.bg.error-subtle |
color.text.error |
color.border.error |
| Info | color.bg.info-subtle |
color.text.info |
color.border.info |
For solid badges/CTAs in semantic colors: the system currently only ships color.bg.destructive as a solid semantic background. Solid success/warning/info solid backgrounds (bg.success, bg.warning, bg.info) are pending — see context/issues.md issue #2.
Text Colors
| Role | Token | CSS Property |
|---|---|---|
| Default body text | color.text.primary |
--force-color-text-primary |
| Labels, nav text, descriptions | color.text.secondary |
--force-color-text-secondary |
| Captions, helper text, timestamps | color.text.tertiary |
--force-color-text-tertiary |
| Placeholder, disabled | color.text.disabled |
--force-color-text-disabled |
| Text on solid primary/destructive bg | color.text.on-primary |
--force-color-text-on-primary (white) |
| Text on solid secondary (cyan) bg | color.text.on-secondary |
--force-color-text-on-secondary (black — cyan is too light for white text) |
| Text on inverted bg | color.text.on-inverse |
--force-color-text-on-inverse (white) |
| Link / ghost button text | color.text.link |
--force-color-text-link |
| Active nav item text | color.text.interactive-active |
--force-color-text-interactive-active |
| Brand emphasis text (on neutral/surface bg only) | color.text.primary-brand |
--force-color-text-primary-brand |
Border Colors
| Role | Token | CSS Property | Value |
|---|---|---|---|
| Default (cards, inputs, separators) | color.border.default |
--force-color-border-default |
neutral-200 |
| Subtle (cards on muted bg) | color.border.muted |
--force-color-border-muted |
neutral-100 |
| Emphasized (hover states) | color.border.strong |
--force-color-border-strong |
neutral-300 |
| Focus ring border | color.border.focus |
--force-color-border-focus |
indigo-500 |
| Error state border | color.border.error |
--force-color-border-error |
cranberry-500 |
| Success accent (left-border alerts) | color.border.success |
--force-color-border-success |
green-500 |
| Warning accent | color.border.warning |
--force-color-border-warning |
orange-500 |
| Info accent | color.border.info |
--force-color-border-info |
blue-500 |
| Primary brand border | color.border.primary |
--force-color-border-primary |
indigo-500 |
| Destructive border | color.border.destructive |
--force-color-border-destructive |
cranberry-500 |
Icon Colors
Use color: inherit by default so icons match their parent text. Override explicitly only when needed.
| Role | Token | CSS Property |
|---|---|---|
| Non-interactive icon | color.icon.default |
--force-color-icon-default |
| Clickable standalone icon | color.icon.interactive |
--force-color-icon-interactive |
| Success icon | color.icon.success |
--force-color-icon-success |
| Warning icon | color.icon.warning |
--force-color-icon-warning |
| Error icon | color.icon.error |
--force-color-icon-error |
| Info icon | color.icon.info |
--force-color-icon-info |
Chart Colors (sequential by series)
Use in order: --force-color-chart-1 (indigo) → --force-color-chart-2 (cyan) → --force-color-chart-3 (green) → --force-color-chart-4 (orange) → --force-color-chart-5 (cranberry) → --force-color-chart-6 (blue).
Dark Mode
Dark theme overrides only color.* and shadow.* tokens. Typography, spacing, radius, motion, z-index, layout, and breakpoint tokens are theme-invariant. Do not add any non-color, non-shadow token to semantic-dark.tokens.json.
Background–Foreground Pairing Rules
Always pair backgrounds with their matching foreground. Never mix tiers:
color.bg.surface+color.text.primaryorcolor.text.secondarycolor.bg.primary(indigo-500) +color.text.on-primary(white)color.bg.secondary(cyan-500) +color.text.on-secondary(black — cyan is too light for white text)color.bg.primary-subtle(indigo-50) +color.text.primary-brand(indigo-500)color.bg.interactive-active(indigo-50) +color.text.interactive-active(indigo-700)color.bg.success-subtle+color.text.success(green-700)color.bg.warning-subtle+color.text.warning(orange-700)color.bg.error-subtle+color.text.error(cranberry-700)color.bg.info-subtle+color.text.info(blue-700)color.bg.inverse+color.text.on-inverse(white)
Zero-contrast traps — DO NOT use these pairings:
The following tokens share the same underlying value as color.bg.primary (indigo-500 in light, indigo-300 in dark) and produce zero contrast when used as foreground on a solid primary background:
color.text.primary-brandoncolor.bg.primary— zero contrast. Usecolor.text.on-primaryinstead.color.text.linkoncolor.bg.primary— zero contrast. Usecolor.text.on-primaryinstead.color.border.primaryoncolor.bg.primary— zero contrast. Border is invisible.color.icon.interactiveoncolor.bg.primary— zero contrast. Use iconcolor: inheritfromcolor.text.on-primary.
The name color.text.primary-brand is a common source of errors — it sounds like "the primary brand text color" and is the intuitive first choice for text on brand-colored elements. It is intended for brand-colored text on neutral or surface backgrounds (e.g., outline button text, inline accents), not for text on solid primary backgrounds.
4. Typography System
Font Families
- UI text:
--force-font-family-default— Noto Sans (with system fallbacks) - Code / technical data:
--force-font-family-code— Noto Sans Mono (with system fallbacks)
Do not override font-family per-component.
Type Scale
| Token | CSS Property | Size | Line Height | Primary Usage |
|---|---|---|---|---|
font.size.xs |
--force-font-size-xs |
12px | tight (1.25) | Captions, labels, badges, helper text. Minimum readable size. |
font.size.sm |
--force-font-size-sm |
14px | tight (1.25) | Default body text. Most UI text. Do NOT use base as the default. |
font.size.base |
--force-font-size-base |
16px | normal (1.5) | Large body text, page descriptions, marketing copy. |
font.size.lg |
--force-font-size-lg |
18px | snug (1.375) | Subsection headers. |
font.size.xl |
--force-font-size-xl |
20px | relaxed (1.75) | Card titles, H2 headings. |
font.size.2xl |
--force-font-size-2xl |
24px | relaxed (1.75) | Section headers. |
font.size.3xl |
--force-font-size-3xl |
30px | relaxed (1.75) | Page titles (PageHeader component). |
font.size.4xl |
--force-font-size-4xl |
36px | relaxed (1.75) | Dashboard display headings. Use sparingly. |
Font Weights
| Token | CSS Property | Value | Usage |
|---|---|---|---|
font.weight.regular |
--force-font-weight-regular |
400 | Body text, descriptions |
font.weight.medium |
--force-font-weight-medium |
500 | Buttons, nav items, labels, badges, table headers |
font.weight.semibold |
--force-font-weight-semibold |
600 | Card titles, section headings |
font.weight.bold |
--force-font-weight-bold |
700 | Page titles, strong emphasis |
Line Height Tokens
| Token | CSS Property | Value | Usage |
|---|---|---|---|
font.lineHeight.tight |
--force-font-line-height-tight |
1.25 | Headings, single-line labels |
font.lineHeight.snug |
--force-font-line-height-snug |
1.375 | Subheadings, medium text |
font.lineHeight.normal |
--force-font-line-height-normal |
1.5 | Default body text |
font.lineHeight.relaxed |
--force-font-line-height-relaxed |
1.75 | Long-form content, descriptions |
Letter Spacing Tokens
| Token | CSS Property | Value | Usage |
|---|---|---|---|
font.letterSpacing.tight |
--force-font-letter-spacing-tight |
-0.01em | Large headings (3xl+) |
font.letterSpacing.normal |
--force-font-letter-spacing-normal |
0em | Default body text |
font.letterSpacing.wide |
--force-font-letter-spacing-wide |
0.025em | Uppercase labels, badge text |
Note: Callout card labels use 0.05em — this is a wider value not yet tokenized (see context/issues.md issue #9).
Heading Conventions
| Heading | Size | Weight | Letter Spacing |
|---|---|---|---|
| Page title (PageHeader) | font.size.3xl (30px) |
bold | tight |
| Section header | font.size.2xl (24px) |
semibold | normal |
| Card title / H2 | font.size.xl (20px) |
semibold | normal |
| Subsection header | font.size.lg (18px) |
semibold | normal |
| Default body | font.size.sm (14px) |
regular | normal |
| Table header | font.size.xs (12px) |
medium | wide (uppercase) |
| Caption / label | font.size.xs (12px) |
medium | wide (uppercase) |
5. Spacing & Layout
Spacing Scale
Base unit: 4px. Every padding, margin, and gap value must use a spacing token.
| Token | CSS Property | Value | Common Usage |
|---|---|---|---|
spacing.1 |
--force-spacing-1 |
4px | Icon-to-label gap, tightest inline spacing |
spacing.2 |
--force-spacing-2 |
8px | Component internal gaps, badge inline padding |
spacing.3 |
--force-spacing-3 |
12px | Input horizontal padding, small component padding |
spacing.4 |
--force-spacing-4 |
16px | Card padding (compact), grid gaps, control bar gaps |
spacing.5 |
--force-spacing-5 |
20px | Medium internal padding |
spacing.6 |
--force-spacing-6 |
24px | Card padding (default), section gaps, layout content padding |
spacing.8 |
--force-spacing-8 |
32px | Section margins |
spacing.10 |
--force-spacing-10 |
40px | Large section separation |
spacing.12 |
--force-spacing-12 |
48px | Major layout breaks |
spacing.16 |
--force-spacing-16 |
64px | Page-level vertical spacing |
spacing.20 |
--force-spacing-20 |
80px | Hero / feature section spacing |
Canonical Spacing Assignments
| Pattern | Token |
|---|---|
| Card internal padding | --force-spacing-6 (24px) |
| Grid gap between cards | --force-spacing-4 (16px) |
| Section gap within a tab | --force-spacing-6 (24px) |
| Layout content padding | --force-spacing-6 (24px) |
| Form field vertical gap | --force-spacing-4 (16px) |
| Button padding (sm) | --force-spacing-3 (12px) |
| Button padding (md) | --force-spacing-4 (16px) |
| Button padding (lg) | --force-spacing-5 (20px) |
| Inline icon-to-text gap | --force-spacing-1 (4px) to --force-spacing-2 (8px) |
Layout Selection
Every Perforce product uses a sidebar — it is the baseline navigation structure. The topbar is the variable. The decision is whether the product needs a persistent horizontal chrome strip above the sidebar, not whether it needs a sidebar at all.
Decision: Does this product need a topbar?
Add a topbar (use the Page Shell pattern) when any of these are true:
- The product has multiple product areas exposed as top-level navigation (e.g., Streams, Repos, CI/CD as distinct sections in the topbar, with the sidebar navigating within the selected area).
- The product participates in the app switcher — the multi-app grid icon that lets users move between Perforce products (analogous to the Google app grid). The app switcher lives in the topbar to the left of the product logo.
- The product needs global search that spans across product areas, surfaced as a persistent trigger in the topbar.
- The product requires a persistent brand identity strip — the product logo/wordmark in the top-right corner alongside global utility icons (user avatar, settings, notifications).
Use sidebar-only (use the Sidebar-Only Shell pattern) when:
- The product has a single navigation tier — all routes fit comfortably in the sidebar without needing top-level product area switching.
- The product is a focused tool, admin panel, documentation site, or internal utility that does not participate in the app switcher or cross-product navigation.
- Global utilities (user avatar, settings) can be placed in the sidebar footer without crowding the navigation.
When in doubt, start with sidebar-only. Adding a topbar later is straightforward — the sidebar structure remains unchanged and the topbar layers on top. Removing a topbar is harder because global utilities and product area navigation must be relocated into the sidebar, which may require restructuring the information architecture.
There is no topbar-only variant. Every Perforce product has a sidebar. If the product has so few routes that a sidebar feels excessive, use the sidebar-only shell with a flat list of nav items — do not invent a horizontal-nav-only layout.
| Signal | Shell | Why |
|---|---|---|
| Multiple product areas | Page Shell | Topbar provides the first tier of navigation; sidebar provides the second |
| App switcher needed | Page Shell | App switcher pattern lives in the topbar |
| Global search across areas | Page Shell | Search trigger belongs in the persistent topbar strip |
| Logo + utility icons need persistent strip | Page Shell | Topbar carries brand identity and global actions |
| Single nav tier, few routes | Sidebar-Only Shell | Keep it simple — one navigation surface |
| Admin panel or internal tool | Sidebar-Only Shell | No cross-product concerns |
| Documentation site | Sidebar-Only Shell | Content-focused, no global chrome needed |
Application Shell Structure
+-------------------------------------------------------------+
| TopBar (height: --force-layout-header-height = 64px) |
| background: --force-color-bg-emphasis |
| position: fixed; z-index: --force-z-index-fixed (300) |
+----------+--------------------------------------------------+
| Sidebar | ╭──────────────────────────────────────────╮ |
| 256px | │ Content Panel │ |
| bg- | │ background: --force-color-bg-surface │ |
| emphasis| │ border-radius: --force-radius-xl (12px) │ |
| | │ padding: --force-layout-content-padding │ |
| | │ scrollable; fills remaining viewport │ |
| | ╰──────────────────────────────────────────╯ |
| | ↑ grey chrome (bg-emphasis) continues behind |
+----------+--------------------------------------------------+
The content area is an inset rounded panel, not a flat edge-to-edge region. The grey chrome background (--force-color-bg-emphasis) is continuous behind the content panel — it extends from the topbar and sidebar to fill the entire viewport. The white content panel sits on top with border-radius: var(--force-radius-xl) (12px), creating a visible rounded frame effect. This is a key visual signature of Perforce products. The panel has a small margin/gap between it and the sidebar/topbar edges so the grey background peeks through, reinforcing the inset appearance.
Layout Dimension Tokens
| Token | CSS Property | Value |
|---|---|---|
layout.header-height |
--force-layout-header-height |
64px (4rem) |
layout.sidebar-width |
--force-layout-sidebar-width |
256px (16rem) |
layout.sidebar-collapsed |
--force-layout-sidebar-collapsed |
64px (4rem) |
layout.content-max-width |
--force-layout-content-max-width |
1400px (87.5rem) — opt-in reading-measure cap for forms/prose only; not applied to the content panel |
layout.content-padding |
--force-layout-content-padding |
24px (1.5rem) |
Responsive Breakpoints
| Token | CSS Property | Value |
|---|---|---|
breakpoint.sm |
--force-breakpoint-sm |
576px |
breakpoint.md |
--force-breakpoint-md |
768px |
breakpoint.lg |
--force-breakpoint-lg |
992px |
breakpoint.xl |
--force-breakpoint-xl |
1200px |
breakpoint.2xl |
--force-breakpoint-2xl |
1400px |
Border Radius
| Token | CSS Property | Value | Usage |
|---|---|---|---|
radius.sm |
--force-radius-sm |
4px | Subtle badges, small tags |
radius.md / radius.button / radius.input |
--force-radius-md |
6px | Buttons, inputs (default) |
radius.lg / radius.card |
--force-radius-lg |
8px | Cards, containers, dropdowns |
radius.xl / radius.modal |
--force-radius-xl |
12px | Modals, large panels |
radius.full / radius.badge |
--force-radius-full |
9999px | Pill badges, avatars, round buttons |
Use the semantic aliases (radius.button, radius.card, radius.modal, radius.badge, radius.input) when available — they make intent explicit and insulate against future scale changes.
Page Layout Patterns
List page (data grid primary content):
PageHeader— title (font.size.3xl, bold) + description (font.size.base,color.text.secondary) + action buttonsControlsBar— search + filters, gap--force-spacing-4DataGrid— bordered container, fills remaining height
Detail page (single entity):
Breadcrumbs— links in--force-color-text-link, chevron separator in--force-color-text-tertiaryDetailPageHeader— title + metadata badges + action dropdownTabList— underline variant; active tab has 3px bottom border in--force-color-border-primary- Tab content — no extra padding; layout handles edge spacing
Overview tab (within detail pages):
.overview { display: flex; flex-direction: column; gap: var(--force-spacing-6); }
.calloutCards { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--force-spacing-4); }
.contentGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--force-spacing-4); align-items: start; }
6. Elevation & Shadows
Shadows communicate floating layer elevation only. They are not used on cards, panels, tables, or any content container that is part of the page flow.
Shadow Scale
| Token | CSS Property | Usage |
|---|---|---|
shadow.xs |
--force-shadow-xs |
Dropdown menus |
shadow.sm |
--force-shadow-sm |
Popovers, select dropdowns |
shadow.md |
--force-shadow-md |
Floating panels, persistent popovers |
shadow.lg |
--force-shadow-lg |
Modals, drawers |
shadow.xl |
--force-shadow-xl |
Wizard panels, major overlays |
Focus Rings
| Token | CSS Property | Usage |
|---|---|---|
shadow.focus-ring |
--force-shadow-focus-ring |
Default keyboard focus (3px indigo-100 spread) |
shadow.focus-ring-error |
--force-shadow-focus-ring-error |
Focused input with validation error (3px cranberry-100 spread) |
Focus ring always pairs with a border color change:
- Default focus:
border-color: var(--force-color-border-focus)+box-shadow: var(--force-shadow-focus-ring) - Error focus:
border-color: var(--force-color-border-error)+box-shadow: var(--force-shadow-focus-ring-error)
What Gets Shadows vs. Borders
| Element | Elevation signal |
|---|---|
| Card | border: 1px solid var(--force-color-border-default) |
| Table | border: 1px solid var(--force-color-border-default) |
| Input | border: 1px solid var(--force-color-border-default) |
| Dropdown menu | --force-shadow-xs + border: 1px solid var(--force-color-border-default) |
| Popover | --force-shadow-sm |
| Floating panel | --force-shadow-md |
| Modal | --force-shadow-lg |
| Wizard panel | --force-shadow-xl |
| Toast | --force-shadow-lg |
| Tooltip | --force-shadow-sm |
7. Motion
Use the shortest duration that still feels responsive. Avoid animation entirely for functional state changes (validation, loading indicators).
Duration Tokens
| Token | CSS Property | Value | Usage |
|---|---|---|---|
duration.fast |
--force-duration-fast |
150ms | Hover state changes, color transitions, toggle switches |
duration.normal |
--force-duration-normal |
250ms | Panel open/close, fade in/out, dropdown appear |
duration.slow |
--force-duration-slow |
350ms | Page transitions, complex reveals |
duration.entrance |
--force-duration-entrance |
500ms | First-paint entrance animations. Use sparingly. |
Easing Tokens
| Token | CSS Property | Curve | Usage |
|---|---|---|---|
easing.standard |
--force-easing-standard |
cubic-bezier(0.4, 0, 0.2, 1) |
Default for most transitions |
easing.decelerate |
--force-easing-decelerate |
cubic-bezier(0, 0, 0.2, 1) |
Enter/appear animations — fast start, soft landing |
easing.accelerate |
--force-easing-accelerate |
cubic-bezier(0.4, 0, 1, 1) |
Exit/leave animations — gentle start, fast exit |
When to Use Which
| Interaction | Duration | Easing |
|---|---|---|
| Button hover color change | duration.fast |
easing.standard |
| Dropdown open | duration.normal |
easing.decelerate |
| Dropdown close | duration.normal |
easing.accelerate |
| Modal open | duration.normal |
easing.decelerate |
| Modal close | duration.fast |
easing.accelerate |
| Toast enter | duration.normal |
easing.decelerate |
| Page transition | duration.slow |
easing.standard |
| Toggle/switch | duration.fast |
easing.standard |
Note: Composed transition shorthand tokens (e.g., transition-colors, transition-shadow) are not yet in the token system — see context/issues.md issue #5. Define these at the component level using var(--force-duration-fast) and var(--force-easing-standard) directly.
8. Interaction Model
States are explicit tokens, never computed. Every interactive element implements this exact state set.
Standard State Progression
default → hover → active/pressed → [selected] → focus → disabled
State Token Assignments
| State | Background | Text | Border |
|---|---|---|---|
| Default | transparent or color.bg.surface |
color.text.secondary |
color.border.default |
| Hover | color.bg.interactive-hover |
color.text.primary |
color.border.default or color.border.strong |
| Selected / Active | color.bg.interactive-active |
color.text.interactive-active |
color.border.primary |
| Focus | inherit background | inherit text | color.border.focus + shadow.focus-ring |
| Disabled | color.bg.muted |
color.text.disabled |
color.border.muted |
Button Variants
| Variant | Default bg | Default text | Hover bg | Notes |
|---|---|---|---|---|
primary |
color.bg.primary (indigo-500) |
color.text.on-primary |
color.bg.primary-hover |
Main CTA |
secondary |
color.bg.surface |
color.text.secondary |
color.bg.interactive-hover |
Bordered |
tertiary |
transparent | color.text.secondary |
color.bg.interactive-hover |
No border |
ghost |
transparent | color.text.link |
color.bg.primary-subtle |
Brand color text |
outline |
transparent | color.text.primary-brand |
color.bg.primary-subtle |
Brand border |
danger |
color.bg.destructive |
color.text.on-primary |
color.bg.destructive-hover |
Destructive action |
outline-danger |
transparent | color.text.error |
color.bg.error-subtle |
Lighter destructive |
text |
transparent | color.text.link |
underline | Inline action |
Navigation State Conventions
- Sidebar active item:
color.bg.interactive-active(indigo-50) +color.text.interactive-active(indigo-700) + 4px left border incolor.border.primary(indigo-500) - Tab active (underline variant): 3px bottom border in
color.border.primary - Tab active (pills variant):
color.bg.interactive-activebackground - Breadcrumb links:
color.text.linkcolor, underline on hover. Current page:color.text.primary, bold, not clickable.
Opacity Tokens
| Token | CSS Property | Value | Usage |
|---|---|---|---|
opacity.disabled |
--force-opacity-disabled |
0.5 | Apply to the entire disabled element |
opacity.subtle |
--force-opacity-subtle |
0.7 | De-emphasis without full disabled treatment |
opacity.overlay |
--force-opacity-overlay |
0.5 | Scrim/overlay elements |
Apply opacity.disabled to the element container, not just the text child.
Z-Index Stack
| Token | CSS Property | Value | Usage |
|---|---|---|---|
zIndex.dropdown |
--force-z-index-dropdown |
100 | Dropdown menus, select lists |
zIndex.sticky |
--force-z-index-sticky |
200 | Sticky table headers |
zIndex.fixed |
--force-z-index-fixed |
300 | Topbar |
zIndex.modal-backdrop |
--force-z-index-modal-backdrop |
400 | Modal scrim |
zIndex.modal |
--force-z-index-modal |
500 | Modal content |
zIndex.popover |
--force-z-index-popover |
600 | Popovers above modals |
zIndex.tooltip |
--force-z-index-tooltip |
700 | Tooltips |
zIndex.toast |
--force-z-index-toast |
800 | Toast notifications |
9. Icons
Set: Material Symbols Rounded. Do not mix icon sets.
Icon Sizes
| Name | Size | Usage |
|---|---|---|
xs |
14px | Inline with small text (font.size.xs) |
sm |
16px | Buttons (sm), table cells, inline with body text |
md |
20px | Default — nav items, buttons (md), form field icons |
lg |
24px | Headers, standalone icons, card titles |
xl |
32px | Feature icons, empty states, onboarding |
Color Rules
- Icons inherit text color by default via
color: inherit. Do not set icon color independently unless the icon is a standalone interactive element or a status indicator. - Standalone interactive icons (buttons without text labels): use
--force-color-icon-interactive. - Status icons: use
--force-color-icon-success,--force-color-icon-warning,--force-color-icon-error,--force-color-icon-info. - Non-interactive decorative icons: use
--force-color-icon-default.
SVG Color Inheritance
Material Symbols Rounded ships SVGs with no fill attribute on <path> elements. Per the SVG spec, the default fill is black — meaning CSS color: inherit and --force-color-icon-* tokens have no effect unless the SVG is configured to use currentColor.
When using SVG-to-React tooling (e.g., vite-plugin-svgr), you must inject fill="currentColor" onto the root <svg> element via svgProps. The commonly-suggested replaceAttrValues option alone is insufficient — it only replaces existing color attributes, and Material Symbols SVGs have none to replace.
Required SVGR configuration (vite-plugin-svgr):
svgr({
svgrOptions: {
svgProps: { fill: "currentColor" }, // injects fill on <svg> root
replaceAttrValues: { // catches any explicit fills
"#000": "currentColor",
black: "currentColor",
},
svgoConfig: {
plugins: [
{ name: "convertColors", params: { currentColor: true } },
],
},
},
}),
svgProps: { fill: "currentColor" }— required. Adds thefillattribute Material Symbols omits.replaceAttrValues— safety net for third-party SVGs that do include explicit color values.svgoConfig.convertColors— normalizes any remaining hardcoded colors during SVGO optimization.
For other SVG pipelines (webpack @svgr/webpack, standalone @svgr/cli, etc.), apply the same svgProps and replaceAttrValues options in the equivalent configuration format.
Guidance
- DO pair icon size with text size:
xsicon withxstext,smicon withsmtext,mdicon withmd–lgtext. - DO provide
aria-labelon icon-only buttons. Icons without text labels are invisible to screen readers. - DON'T use icons as the sole indicator of meaning — always pair with text or an
aria-label. - DON'T mix filled and outlined styles within the same interface — Force UI uses Rounded exclusively.
10. Accessibility Standards
Baseline: WCAG 2.1 Level AA.
Color Contrast
- Normal text (< 18px or < 14px bold): minimum 4.5:1 ratio
- Large text (>= 18px or >= 14px bold): minimum 3:1 ratio
- UI components and focus indicators: minimum 3:1 ratio against adjacent colors
Verified pairs:
| Pair | Ratio |
|---|---|
color.text.primary (#000) on color.bg.surface (#fff) |
21:1 |
color.text.secondary (neutral-800 #26262e) on white |
~14:1 |
color.text.link (indigo-500 #5405ff) on white |
~5.6:1 |
color.text.error (cranberry-700 #a30e1c) on color.bg.error-subtle |
passes AA |
color.text.success (green-700 #0f660f) on color.bg.success-subtle |
passes AA |
Do not use color.text.disabled (neutral-400) for readable content — it does not meet AA contrast on white.
Focus Management
- Every interactive element must have a visible focus indicator using
--force-shadow-focus-ringand--force-color-border-focus. - Focus rings must never be suppressed with
outline: nonewithout providing the custom ring replacement. - Modal open: move focus to the first foceable element inside the modal.
- Modal close: return focus to the trigger element.
- Escape key always closes: modals, dropdowns, popovers, and tooltips.
- When modal is open: lock body scroll.
Keyboard Navigation
- All interactive elements reachable by Tab / Shift+Tab.
- Menus and listboxes: Arrow Up/Down to navigate items, Enter/Space to select, Escape to close.
- Tab navigation (underline/pills): Arrow Left/Right to switch tabs.
- Data tables: Tab moves between interactive cells; row selection via Space.
Minimum Target Sizes
- Buttons: minimum height 32px (
sm), prefer 40px (md) for primary actions. - Interactive icons without text labels: minimum 32px × 32px hit area.
- Form inputs: minimum height 32px (
sm), prefer 40px (md).
Color Is Never the Only Signal
- Status (error, success, warning, info) must use both color AND an icon or text label.
- Disabled state must use both reduced opacity AND non-interactive cursor, not color alone.
- Selected/active state must use both background color AND a border or typographic treatment.
11. Composition Rules
Card Pattern
background: var(--force-color-bg-surface);
border: 1px solid var(--force-color-border-default);
border-radius: var(--force-radius-card); /* 8px */
padding: var(--force-spacing-6); /* 24px */
Card on a bg.muted surface: use border-color: var(--force-color-border-muted) (one step lighter).
Card header (when present):
padding-bottom: var(--force-spacing-4);
border-bottom: 1px solid var(--force-color-border-default);
margin-bottom: var(--force-spacing-4);
Form Input Pattern
height: 40px; /* md size */
padding: 0 var(--force-spacing-3); /* 0 12px */
border: 1px solid var(--force-color-border-default);
border-radius: var(--force-radius-input); /* 6px */
background: var(--force-color-bg-surface);
font-size: var(--force-font-size-sm); /* 14px */
Alert / Callout Pattern
Left-border accent style:
padding: var(--force-spacing-4);
border-radius: var(--force-radius-lg);
border-left: 4px solid var(--force-color-border-{variant});
background: var(--force-color-bg-{variant}-subtle);
color: var(--force-color-text-{variant});
Modal Pattern
/* Backdrop */
background: var(--force-color-bg-overlay);
z-index: var(--force-z-index-modal-backdrop);
/* Panel */
background: var(--force-color-bg-surface);
border-radius: var(--force-radius-modal); /* 12px */
box-shadow: var(--force-shadow-lg);
z-index: var(--force-z-index-modal);
Modal widths (not tokenized, use as documented constants): small 400px, medium 600px, large 900px, xl 1200px.
Dropdown / Popover Pattern
background: var(--force-color-bg-surface);
border: 1px solid var(--force-color-border-default);
border-radius: var(--force-radius-lg); /* 8px */
box-shadow: var(--force-shadow-xs); /* dropdown */ /* or shadow-sm for popover */
z-index: var(--force-z-index-dropdown);
Badge Pattern
Subtle (default): color.bg.{variant}-subtle background + color.text.{variant} text + radius.badge (pill)
Outline: transparent bg + color.text.primary-brand or variant text + 1px color.border.{variant} border + radius.badge
Strong: solid color.bg.{variant} + color.text.on-primary (white) + radius.badge. Exception: color.bg.secondary (cyan) pairs with color.text.on-secondary (black) — white fails contrast on cyan.
Badge text: font.size.xs (12px), font.weight.medium (500), uppercase, font.letterSpacing.wide.
Data Table Pattern
Container:
border: 1px solid var(--force-color-border-default);
border-radius: var(--force-radius-lg);
overflow: hidden;
Header row: color.bg.muted background, font.size.xs, font.weight.medium, uppercase, color.text.tertiary.
Body row: font.size.sm, color.text.primary, border-bottom: 1px solid var(--force-color-border-muted).
Hover row: color.bg.interactive-hover.
Selected row: color.bg.interactive-active.
Actions column: always rightmost, pinned, 100px, not sortable/filterable.
12. Do / Don't
Color
- DO use
--force-color-*tokens for every color value. - DO pair backgrounds with their designated foreground tokens (see Section 3, Background–Foreground Pairing Rules).
- DO use semantic status colors (success, warning, error, info) only to communicate system state.
- DON'T use raw hex values anywhere in component code.
- DON'T reference primitive tokens (e.g.,
color.neutral.200,color.indigo.500) in component code — use semantic tokens only. - DON'T use semantic status colors (green, orange, cranberry, blue) for decorative or organizational purposes.
- DON'T use
color.text.disabledfor readable content — it fails contrast requirements. - DON'T use color alone to communicate state (error, selected, disabled). Always add a secondary signal.
- DON'T apply
--force-color-bg-emphasisto content areas — it is for navigation chrome only.
Shadows & Borders
- DO use
border: 1px solid var(--force-color-border-default)to define card and container structure. - DO use shadow tokens only on floating/detached elements (modals, dropdowns, tooltips, toasts, popovers).
- DON'T add a shadow to a card, panel, table, or any element that is part of the page flow.
- DON'T use
box-shadowfor visual decoration or to suggest importance. - DON'T stack a border and a shadow on the same element unless it is a dropdown (which uses both).
Spacing
- DO use
--force-spacing-*tokens for all padding, margin, and gap values. - DO use
--force-spacing-6(24px) for card internal padding. - DO use
--force-spacing-4(16px) for grid gaps between cards and between form fields. - DON'T use arbitrary pixel values (e.g.,
padding: 13pxormargin: 7px). - DON'T use values that fall between steps on the 4px grid.
Typography
- DO use
--force-font-size-sm(14px) as the default body text size. - DO use
--force-font-weight-medium(500) for button labels, nav items, badges, and table headers. - DON'T use
--force-font-size-base(16px) as the default body size — it is for descriptions and large text only. - DON'T apply font-family overrides per-component — use
--force-font-family-defaultsystem-wide. - DON'T use font size alone to establish visual hierarchy — always pair size with a weight change.
Z-Index
- DO use
--force-z-index-*tokens for all stacking contexts. - DON'T use arbitrary z-index values (e.g.,
z-index: 9999,z-index: 50). - DON'T create new stacking layers outside the defined 8-layer scale without a documented reason.
Motion
- DO use
--force-duration-fast(150ms) for hover and color changes. - DO use
--force-duration-normal(250ms) for panel and dropdown transitions. - DON'T animate functional state changes (form validation errors, loading indicators).
- DON'T use
duration.entrance(500ms) for routine interactions — it is for first-paint only.
Interaction & States
- DO implement all five states for every interactive element: default, hover, active/selected, focus, disabled.
- DO use explicit state tokens — never compute hover/active colors with
filter,brightness(), or OKLCHcalc(). - DO apply
opacity.disabled(0.5) to the entire disabled element container, not just the text. - DON'T suppress focus outlines without providing the
--force-shadow-focus-ringreplacement. - DON'T use
pointer-events: nonealone to disable an element — also setaria-disabledand apply disabled visual tokens.
Layout
- DON'T add padding to tab content containers — the shell layout handles edge spacing.
- DO let the main content panel fill the available width between the sidebar and the viewport edge. Do NOT cap the content panel with a fixed max-width.
- DO apply an inner-column reading-measure cap (
--force-layout-content-max-width, 1400px, or a tighter value where appropriate) to article-like content: long-form prose, documentation/markdown pages, release notes, settings forms. Reading comfort drops sharply as line length grows past ~75 characters, so narrow the column for these surfaces. When the capped column is the sole content of the page, center it horizontally within the panel so empty space balances on both sides. - DO let data-dense content fill the full panel width: dashboards, data tables, list views, detail views, wizards, multi-column forms, and any layout composed of components that can consume horizontal space. The cap is not appropriate here — it creates wasted chrome on wide monitors and cramps the components that were designed to use the room.
- DON'T apply the reading-measure cap to the content panel itself. It is only ever applied to an inner column whose content type is article-like.
- DON'T remove or flatten the content panel's rounded corners (
--force-radius-xl). The inset rounded panel on the grey chrome is a key Perforce visual signature. - DON'T put a border on the sidebar's right edge — the inset content panel creates the visual separation.
- DON'T set custom sidebar or topbar dimensions — use the layout tokens.
Accessibility
- DON'T rely on color alone to convey meaning, status, or state.
- DON'T set
outline: nonewithout replacing with the Force focus ring (--force-shadow-focus-ring). - DON'T use text smaller than
--force-font-size-xs(12px). - DON'T lock keyboard focus inside a component unless it is a modal or dialog with proper ARIA role.
Token System
- DON'T modify primitive tokens to fix a semantic issue — add or adjust semantic tokens instead.
- DON'T add color or shadow tokens to
semantic-dark.tokens.jsonwithout a corresponding entry insemantic-light.tokens.json. - DON'T add typography, spacing, radius, motion, z-index, or layout tokens to
semantic-dark.tokens.json. - DON'T ship broken alias references — validate that every
{path.to.token}in semantic files resolves in primitives before committing.
Force UI Design System — design.md
Spec Layer 2 of 3. Layer 1: token JSON files (tokens/). Layer 3: UI pattern files (patterns/).
Last updated: 2026-04-16