# zc-ui-dev — Zync UI Development Reference

Agent context for ALL UI work on Zync.is. Read before touching any component, page, or style. This file is authoritative. Where it conflicts with foundation-design-system.md, this file wins.

---

## Stack

- `apps/zync-app` — Vite + React + React Router v6 + Zustand + TanStack Query
- `apps/zync-www` — Astro (public marketing site + auth entry)
- `packages/ui` — Shared primitives (Radix UI via shadcn copy-paste + Tailwind + CSS vars)
- Styling: Tailwind utilities + CSS custom properties only. No inline styles. No hardcoded values ever.

---

## COLOR ARCHITECTURE

### Law: OKLCH Only

**Banned everywhere in source:** `#hex`, `rgb()`, `rgba()`, `hsl()`, `hsla()`.
ESLint rule `no-hardcoded-colors` enforces. Any hex/rgb found in a PR is a blocker.

### Dual Theme System (spec 114 — authoritative)

Zync supports dark and light modes. Theme applied via `.dark` class on `<html>` (server-set from cookie before CSS loads — no flash).

**Toggle:** `document.documentElement.classList.toggle('dark')` + `PATCH /api/user/preferences { ui_theme }`.

**Default for new users:** dark.

> ⚠️ Old warm-paper/oxblood palette (`oklch(97% 0.006 60)` bg, `oklch(35% 0.14 14)` accent) is **retired**. Do not use it. spec 114 supersedes.

### Dark Mode Token Set (`html.dark`)

```css
html.dark {
  --bg:             oklch(13% 0.035 240);   /* deep navy — page background */
  --surface:        oklch(16% 0.042 240);   /* cards, panels */
  --elevated:       oklch(18% 0.048 240);   /* modals, dropdowns */
  --hover:          oklch(21% 0.05 240);    /* hover state */

  --accent:         oklch(88% 0.16 191);    /* bright teal (#00f0e8) — primary action */
  --accent-mid:     oklch(78% 0.14 191);    /* hover on accent */
  --accent-soft:    oklch(18% 0.06 195);    /* badge bg, selected row */
  --accent-border:  oklch(32% 0.08 195);    /* teal border on dark */

  --ink:            oklch(95% 0.016 195);   /* primary text */
  --ink-soft:       oklch(80% 0.028 210);   /* secondary text */
  --ink-faint:      oklch(55% 0.025 215);   /* placeholder, disabled */
  --ink-on-accent:  oklch(13% 0.035 240);   /* text on --accent bg */

  --line:           oklch(24% 0.045 230);   /* default border */
  --line-subtle:    oklch(19% 0.04 235);    /* subtle divider */

  --success:    oklch(72% 0.16 155);  --success-bg: oklch(18% 0.04 155);
  --warning:    oklch(82% 0.18 85);   --warning-bg: oklch(17% 0.04 85);
  --danger:     oklch(68% 0.22 25);   --danger-bg:  oklch(17% 0.05 25);
}
```

### Light Mode Token Set (`:root` — no class)

```css
:root {
  --bg:             oklch(100% 0 0);          /* pure white */
  --surface:        oklch(95% 0.016 195);     /* pale teal cards */
  --elevated:       oklch(100% 0 0);          /* white modals */
  --hover:          oklch(92% 0.022 195);     /* light teal hover */

  --accent:         oklch(44% 0.12 195);      /* dark teal (VirtuAc #007070) */
  --accent-mid:     oklch(54% 0.13 195);      /* hover on accent */
  --accent-soft:    oklch(95% 0.022 195);     /* selected row, badge bg */
  --accent-border:  oklch(82% 0.04 195);      /* light teal border */

  --ink:            oklch(12% 0.04 240);      /* near-black navy */
  --ink-soft:       oklch(22% 0.045 240);     /* secondary text */
  --ink-faint:      oklch(43% 0.035 235);     /* placeholder, disabled */
  --ink-on-accent:  oklch(100% 0 0);          /* white text on teal */

  --line:           oklch(90% 0.03 195);      /* light teal border */
  --line-subtle:    oklch(96% 0.014 195);     /* barely-visible divider */

  --success:    oklch(40% 0.13 155);  --success-bg: oklch(95% 0.025 155);
  --warning:    oklch(52% 0.14 80);   --warning-bg: oklch(97% 0.022 85);
  --danger:     oklch(44% 0.19 25);   --danger-bg:  oklch(97% 0.018 25);
}
```

### Z-Index Scale (`:root`, mode-independent)

```css
:root {
  --z-base:     0;
  --z-sticky:   100;   /* sticky headers */
  --z-dropdown: 200;   /* dropdowns, popovers */
  --z-modal:    300;   /* dialogs, sheets */
  --z-toast:    400;   /* toast notifications */
  --z-tooltip:  500;   /* tooltips */

  --radius: 4px;
}
```

### Contrast Rules (WCAG 2.1 AA — spec 115)

| Use | Min contrast | Token |
|-----|-------------|-------|
| Body / label text | 4.5:1 | `--ink` or `--ink-soft` |
| Disabled / placeholder | exempt | `--ink-faint` only |
| Text on `--accent` bg | 4.5:1 | `--ink-on-accent` |
| Focus outline | — | `outline: 2px solid var(--accent); outline-offset: 2px` |

**Never use `--ink-faint` for readable text.**

### Theme Testing Rule

All components must be tested in BOTH dark and light themes (Storybook: theme toggle in addon panel). `color-scheme` property must match:
```css
html.dark     { color-scheme: dark; }
html:not(.dark) { color-scheme: light; }
```

### Accent Usage Rule

Max ONE accent-colored element per visual section. Do not stack:
- Accent button + accent badge + accent border in same card. Pick one.
- Active sidebar item uses `--accent-soft` bg + `--accent` left border. That counts as one.

---

## SPACING GRID

Strict 8px baseline. Tailwind spacing must map to these multiples only.

**Allowed gap/padding/margin values (px):** 8, 16, 24, 32, 48, 64, 96
**Forbidden:** 4, 6, 10, 12, 20, 28, 36, 40 — any value not in the allowed list.

Exception: `2px` for internal padding within tight primitives (e.g. badge padding-x). Document it with a comment.

In Tailwind: use `gap-2` (8px), `gap-4` (16px), `gap-6` (24px), `gap-8` (32px), `gap-12` (48px), `gap-16` (64px), `gap-24` (96px).

**The 4px half-unit is forbidden for spacing.** Only used for `--radius` (border radius, not spacing).

---

## BORDER RADIUS

One value: `--radius: 4px`. Applied to everything: buttons, cards, inputs, badges, modals, chips.
`rounded` Tailwind class maps to `var(--radius)`.

**Forbidden:** Stepped ladders. Never `rounded-sm` on an input inside `rounded-lg` card inside `rounded-xl` modal. If the design spec shows it, the spec is wrong. All nested elements use `--radius`.

---

## LAYOUT

- 12-column grid.
- Mandate asymmetry. Headlines span odd column counts. Never center-align hero copy.
- Headlines: prefer `col-start-2 col-end-9` or similar. Avoid `col-span-12` centered.
- Negative space is a layout element. Never fill space to avoid "emptiness." Emptiness reads as calm.

---

## TYPOGRAPHY

```css
--font-sans: 'Inter Variable', ui-sans-serif, system-ui;
--font-mono: 'JetBrains Mono Variable', ui-monospace;
```

Type scale (rem, base 16px):
- `text-xs`: 0.75rem / 12px
- `text-sm`: 0.875rem / 14px
- `text-base`: 1rem / 16px (body default)
- `text-lg`: 1.125rem / 18px
- `text-xl`: 1.25rem / 20px
- `text-2xl`: 1.5rem / 24px
- `text-3xl`: 1.875rem / 30px

All sizes mapped to Tailwind utilities. No bare `font-size: 15px` in CSS.

---

## INTERACTION STATES

**Allowed effects:**
- Opacity shift: `transition-opacity duration-150` (150ms)
- 1px underline reveal: `hover:underline underline-offset-2`
- Background shift on `--surface` ↔ lighter: `hover:bg-[--surface]/80`

**Banned:**
- Glassmorphism (`backdrop-blur` + semi-transparent bg)
- Drop-shadow layering (no shadow-on-hover tricks)
- Color gradients (except pure black/white text-legibility masks over images)
- Transform scale on hover (`hover:scale-*`) — it's decorative noise

---

## COMPONENT REGISTRY

All primitives live in `packages/ui/src/`. Use ONLY these. Do not invent raw HTML equivalents in pages.

### Primitives
| Component | Import path | Use for |
|-----------|------------|---------|
| `Button` | `@zync/ui/primitives/button` | All clickable actions |
| `Input` | `@zync/ui/primitives/input` | Text inputs |
| `Badge` | `@zync/ui/primitives/badge` | Status, labels, counts |
| `Avatar` | `@zync/ui/primitives/avatar` | User/customer avatars |
| `Card` | `@zync/ui/primitives/card` | Content containers |
| `Checkbox` | `@zync/ui/primitives/checkbox` | Multi-select |
| `Switch` | `@zync/ui/primitives/switch` | Boolean toggles |
| `Select` | `@zync/ui/primitives/select` | Dropdowns |
| `Textarea` | `@zync/ui/primitives/textarea` | Multi-line input |
| `Tooltip` | `@zync/ui/primitives/tooltip` | Hover labels |
| `Separator` | `@zync/ui/primitives/separator` | Dividers |
| `Skeleton` | `@zync/ui/primitives/skeleton` | Loading states |

### Layout
| Component | Use for |
|-----------|---------|
| `Stack` | Vertical/horizontal flex container |
| `Container` | Page-width wrapper |
| `Divider` | Semantic horizontal rule |

### Overlays
| Component | Use for |
|-----------|---------|
| `Dialog` | Confirmation modals, forms |
| `Sheet` | Slide-in drawer (right panel for detail views) |
| `DropdownMenu` | Context menus, action menus |
| `Popover` | Inline rich content |
| `Command` | `⌘K` search palette (cmdk) |

### Forms
| Component | Use for |
|-----------|---------|
| `Form` | react-hook-form + zod wrapper |
| `FormField` | Field with label + error |
| `FormLabel` | Label primitive |
| `FormError` | Inline error message |

### Feedback
| Component | Use for |
|-----------|---------|
| `Alert` | Inline status messages |
| `Toast` | Sonner toasts |
| `Spinner` | Loading indicators |
| `Progress` | Progress bars |

### Data Display
| Component | Use for |
|-----------|---------|
| `DataTable` | TanStack Table v8 — sortable, filterable, paginated. **Blessed** tabular component (div-based DOM). |
| `StatCard` | KPI/metric cards on dashboard |
| `EmptyState` | Zero-data states (see below) |

> **DEPRECATED — `Table`/`Thead`/`Tbody`/`Tr`/`Th`/`Td` primitive** (`packages/ui/src/data-display/table.tsx`): renders real `<table>` DOM and is **banned in app feature code** (enforced by slop-gate rule `no-zync-ui-table-primitive`). Use `DataTable` or a `<div>`+CSS-grid table instead. The primitive remains only for the PDF/print exception below.

---

## LOADING STATES

**Rule: structural skeletons only. No shimmer animations.**

Skeleton blocks must mirror the shape and layout of the real content they replace:
- A 3-column stat card row → 3 `<Skeleton>` blocks sized to match each card's width and height
- A data table → skeleton rows matching the column widths of the real table header
- A sidebar list → skeleton items matching label + avatar/icon slot dimensions
- A detail panel → skeleton blocks for each labeled field in its final position

**Banned:**
- Shimmer / pulse CSS animations (`animate-pulse`, `animate-shimmer`, any keyframe sweep) — banned
- Generic "5 rows of equal-height bars" that don't match the final layout shape
- Full-page spinners — section-level skeletons only
- `<Spinner>` for data loading — `<Spinner>` is for action feedback (button submit, file upload) only

**Implementation:** Use `<Skeleton className="..." />` with explicit width/height matching the real element. Compose skeleton variants inside the same component that renders real content, switching on `isLoading`.

---

## EMPTY STATES

Pattern: `<EmptyState heading="..." action={{ label: "...", href: "..." }} />`.

Rules:
- One sentence. Conversational. No marketing language.
- One action. No secondary link.
- No illustration. No icon (decorative).
- Copy sounds like a person, not a product.

### Canonical Copy

| Context | Heading | Action label |
|---------|---------|-------------|
| Tasks (no tasks) | "Nothing to do yet." | "Add a task" |
| Projects (none) | "No projects here." | "Start a project" |
| Customers (none) | "No customers yet." | "Add a customer" |
| Invoices (none) | "No invoices yet." | "Create an invoice" |
| Expenses (none) | "No expenses recorded." | "Add an expense" |
| Support tickets (none) | "No tickets open." | "Create a ticket" |
| KB articles (none) | "Nothing in here yet." | "Write an article" |
| Marketing leads (none) | "No leads yet." | "Add a lead" |
| Calendar (no events) | "Nothing scheduled." | "Add an event" |
| Contractor bills (none) | "No payout bills yet." | "Generate a bill" |
| Search (no results) | "Nothing matched that." | (none — just clear search) |
| Activity feed (empty) | "Nothing happened yet." | (none) |

---

## MODULE AWARENESS

Modules can be disabled per tenant. UI rules:
1. Disabled module → sidebar nav item hidden (not shown at all)
2. Direct URL to disabled module → redirect home with toast "This module is turned off"
3. Dashboard KPI cards for disabled modules → hidden (not "N/A")
4. Cross-module references (e.g. task picker on project form when Tasks disabled) → field hidden

Hook: `useModuleEnabled(moduleId: ModuleId): boolean`
Gate component: `<ModuleGate module="tasks"><TaskPicker /></ModuleGate>` — renders null if disabled.

---

## TIER GATING

Sidebar items for tier-gated features show an upgrade badge (not hidden) when tenant is below required tier.
Clicking badge → opens upgrade modal (`/settings/plan`).

Hook: `useTierGate(minimum: TenantTier): { allowed: boolean; upgrade: () => void }`

Do NOT hide tier-gated items. Show them with the badge — user needs to know the feature exists.

---

## RTL SUPPORT

Hebrew UI (`dir="rtl"`). All layout must work mirrored:
- `ms-*` / `me-*` (margin-start/end) instead of `ml-*` / `mr-*`
- `ps-*` / `pe-*` (padding-start/end) instead of `pl-*` / `pr-*`
- Flex row direction: use `rtl:flex-row-reverse` where directional
- Sidebar: flips automatically via `dir` on root `<html>`

---

## ANTI-PATTERNS (INSTANT FAILURE — DO NOT SHIP)

Any output containing these is rejected regardless of other quality:

1. **Centered hero text + 2 stacked CTAs over a gradient background** — banned
2. **"Trusted by" / client logo strip under the hero** — banned
3. **Bento box grids** (3–4 icon/heading/paragraph cards in a row) — banned
4. **Emojis as bullets, section markers, or UI icons** — banned
5. **Decorative icon usage** (Lucide/Heroicons icons placed to fill empty space) — banned
6. **Floating cards with heavy drop shadows** — banned
7. **Any hex/rgb/hsl color in source** — banned (linting blocks it anyway)
8. **Spacing values not on the 8px grid** (e.g. 12px, 20px, 10px gap) — banned
9. **Multiple radius values** (e.g. `rounded-sm` inside `rounded-lg`) — banned
10. **Glassmorphism / backdrop-blur opacity tricks** — banned
11. **Shadow-on-hover lift effects** — banned
12. **More than one accent-colored element per section** — banned
13. **Shimmer/pulse skeleton animations** (`animate-pulse`, `animate-shimmer`, sweep keyframes) — banned
14. **Generic equal-height skeleton rows** that don't match the final layout shape — banned
15. **Full-page spinner for data loading** — section-level structural skeletons only
16. **`<table>` / `<thead>` / `<tbody>` / `<tr>` / `<td>` / `<th>` / `<tfoot>` HTML elements** — banned. Always use `<div>`-based tabular layouts (CSS grid or flex with explicit column widths). Exception: server-side HTML string generation for PDF/print (e.g. ar-aging-pdf.ts). **Also banned: the `@zync/ui` `Table`/`Thead`/`Tbody`/`Tr`/`Th`/`Td` primitive** (renders the same `<table>` DOM) — use `DataTable` or div-grid.

---

## TABULAR DATA

**Never use `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>`, `<th>`, or `<tfoot>` — nor the deprecated `@zync/ui` `Table` primitive.** Build tabular layouts with `<div>` + CSS grid. A div-grid has no native table semantics, so **carry the ARIA role tree** (`table` → `row` → `columnheader`/`cell`) — all or nothing; never a partial/orphan role (`role="grid"` with no row/cell descendants is invalid).

```tsx
{/* Container carries role="table" */}
<div role="table" aria-label="...">
  {/* Header row */}
  <div role="row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr' }}>
    <div role="columnheader" className="px-4 py-4 text-end font-medium text-ink-soft">Column A</div>
    <div role="columnheader" className="px-4 py-4 text-end font-medium text-ink-soft">Column B</div>
    <div role="columnheader" className="px-4 py-4 text-end font-medium text-ink-soft">Column C</div>
  </div>
  {/* Data rows */}
  {items.map((item) => (
    <div
      key={item.id}
      role="row"
      style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr' }}
      className="border-t border-line hover:bg-hover cursor-pointer"
      onClick={() => handleClick(item)}
    >
      <div role="cell" className="px-4 py-4 text-ink">{item.a}</div>
      <div role="cell" className="px-4 py-4 text-ink-soft">{item.b}</div>
      <div role="cell" className="px-4 py-4 text-ink">{item.c}</div>
    </div>
  ))}
</div>
```

Keep `gridTemplateColumns` in a single shared constant when a header and its rows live in separate components, so the column count cannot drift. For `colSpan`-style spanning, use `gridColumn: 'span 3'` (or whatever count) on the div.

Exception: Server-side HTML string generation for PDF/print contexts (e.g. `ar-aging-pdf.ts`, `pdf-preview.ts`) may use `<table>` since they are not React components and need semantic HTML for print renderers.

---

## DESIGN SYSTEM PAGE

Route: `/design-system` (dev/staging only, guarded by env flag)

When adding a new primitive, add it to this page in the same PR. The page is the component registry — if a component isn't on this page, it doesn't officially exist.

Sections: Colors → Typography → Spacing → Primitives → Layout → Overlays → Forms → Navigation → Feedback → Data Display → RTL Preview

---

## FILE LOCATIONS

```
packages/ui/src/
├── primitives/     # Atoms
├── layout/         # Stack, Container, Divider
├── forms/          # Form, FormField, FormLabel, FormError
├── overlays/       # Dialog, Sheet, DropdownMenu, Popover, Command
├── navigation/     # Tabs, Breadcrumb, Pagination
├── feedback/       # Alert, Toast, Spinner, Progress
├── data-display/   # DataTable, StatCard, EmptyState
└── tokens/index.css  # Single source of truth for CSS vars

apps/zync-app/src/
├── pages/          # Route-level components (no raw HTML — primitives only)
├── components/     # App-specific compositions (use primitives internally)
└── hooks/          # useModuleEnabled, useTierGate, etc.
```

---

## CHECKLIST BEFORE SUBMITTING UI CODE

- [ ] Zero hex/rgb/hsl values anywhere in the diff
- [ ] All CSS vars use the current token set (dark + light mode variants) — no oxblood/warm-paper values
- [ ] Component tested in BOTH dark and light themes
- [ ] All spacing values on the 8px grid (8, 16, 24, 32, 48, 64, 96)
- [ ] Single `--radius` / `rounded` value — no ladder
- [ ] Only primitives from `packages/ui` used in pages
- [ ] Module-gated content wrapped in `<ModuleGate>`
- [ ] Tier-gated items show upgrade badge, not hidden
- [ ] Empty states use `<EmptyState>` primitive with approved copy
- [ ] Loading states use structural `<Skeleton>` matching final layout (no shimmer, no full-page spinner)
- [ ] No anti-patterns from the list above
- [ ] RTL-safe margin/padding utilities (`ms-*`, `me-*`, `ps-*`, `pe-*`)
- [ ] New primitives added to `/design-system` page
- [ ] Accent color used max once per section

---

## Learned Rules

### zync-ui-compound-api | fired:1 | 2026-06-10
Importing `DialogContent`, `DialogHeader` as flat named exports from `@zync/ui` → wrong; the package uses a compound-component API (`Dialog.Content`, `Dialog.Footer`). Wave-7 wrote against an assumed flat API → 186 new TS errors.
Prevent: read `packages/ui/src/index.ts` (and the primitive's export shape) BEFORE writing any component that consumes `@zync/ui`; never assume flat vs compound exports.

### sweep-sibling-files-same-api-drift | fired:1 | 2026-06-10
Fixing one file's `@zync/ui` API-drift errors then declaring the floor clean → wrong; sibling files (TimeEntrySelector.tsx after AddToInvoiceButton.tsx) had the identical flat-import pattern, 207 more errors.
Prevent: after fixing an API-drift pattern in one file, grep the whole app for the same import pattern and fix all occurrences in the same sweep.

### system-module-hidden-from-ui | fired:1 | 2026-06-10
Showing the System module in the module-management UI as a user-togglable module → wrong; System is always-on and transparent — users have no interest in or control over it.
Prevent: exclude the System module entirely from module-management UI and any per-tenant module toggle list; it is never user-facing.

### grep-multiline-import-false-negative | fired:1 | 2026-06-10
Single-line grep for `import { Table } from '@zync/ui'` returned 0 → claimed the primitive unused → wrong; the imports spanned lines 14-24 (Table/Thead/Tr multi-line), code-reviewer caught it. Line-oriented scans also make slop-gate import rules silently miss multi-line imports.
Prevent: to assert a symbol is absent from `@zync/ui` imports, use `rg -U`/multiline or open the file; never trust a single-line grep's "0 hits" for import-shape checks.

