# Kasy Design System

**Version 1.2.7** · last updated 2026-07-22

The single source of truth for how the app looks. Everything here lives in code
under `lib/core/theme/` and `lib/components/`, so changing a token in one place
updates the whole app (web, Android, iOS) at once. This document just writes the
rules down so anyone, including projects generated by the Kasy CLI, inherits the
same standard without guessing.

Design principle: **clean, modern, minimal**. Lots of surface, one accent colour
used sparingly, no loud weights. The Home screen (sidebar + header + elevated
`KasyCard`) is the reference for "what good looks like"; new screens should
mirror it.

---

## Parity hammer (Figma ↔ kit ↔ Design System)

One place edits the number; everyone else reads it.

| Layer | Owns |
| ----- | ---- |
| **Figma 01** (Variables + Effect Styles) | Token numbers (color, spacing, radius, shadow, type, icon) |
| **`lib/core/theme`** | 1:1 mirror of those numbers |
| **Components + Design System screen** | Consume theme helpers only (no invented demos) |
| **Figma 02 / 03** | Bind the same Variables/Effects (do not invent values) |

Conflict on a token number → update theme from Figma 01. Design System tile shows
X while a component uses Y → both wrong; share one helper. Figma 02/03 frame
differs from the kit → fix Figma after the kit mirrors 01. Shadow taste:
**light / clean**.

**Overlay** is one family: Menu · Dropdown · Popover · notifications bell →
`KasyShadows.overlayPanel` / Figma `shadow/overlay`.

| DS tile | Dart helper | Components | Figma |
| ------- | ----------- | ---------- | ----- |
| Card | `surfaceOf` / `cardElevated` (filled = flat) | `KasyCard` elevated | `shadow/surface` |
| Field | `fieldOf` / `inputField` | `KasyTextField` primary | `shadow/field` |
| Switch | `switchControlOf` | `KasySwitch` thumb | `shadow/switch` |
| Tab | `tabOf` | `KasyTabs` pill | `shadow/tab` |
| Overlay | `overlayPanel` | Menu, Dropdown, Popover, bell | `shadow/overlay` |
| Inner | `innerShadowOf` | inset token | `shadow/inner` |
| Blur / Backdrop | `frostedBlur` / `modalScrimBlur` | frosted chrome / modal | `blur/frosted` · `blur/scrim` |
| Focus | `focusRing` / `focusRingField` | keyboard focus | `focus/ring` · `focus/ring-field` |

Exception: paywalls **Solo / Compare / Trial** stay creative skins. Unlock follows
tokens.

---

## The golden rule

Use **tokens and roles**, never bespoke values.

| Instead of…                         | Use…                                         |
| ----------------------------------- | -------------------------------------------- |
| `fontSize: 16, fontWeight: w600`    | `context.textTheme.*` / `KasyTextTheme.*`    |
| `Icon(size: 20)`                    | `Icon(size: KasyIconSize.lg)`                |
| `EdgeInsets.all(16)`                | `EdgeInsets.all(KasySpacing.md)`             |
| `BorderRadius.circular(16)`         | `BorderRadius.circular(KasyRadius.lg)`       |
| `Color(0xFF…)` / `Colors.*`         | `context.colors.*`                           |
| `ElevatedButton`, `TextButton`      | `KasyButton`                                 |
| `AlertDialog`, `SnackBar`           | `showKasyConfirmDialog`, `showKasyToast`     |

A bespoke number is only acceptable when a component is being *calibrated* (the
sidebar, the date-picker grid, badges) and there is no token that fits. When in
doubt, add or reuse a role rather than hardcoding.

---

## Typography — `KasyTextTheme` (`lib/core/theme/texts.dart`)

### Type scale (Poppins)

Single family (Figma `font/display` + `font/body` → **Poppins**). Hierarchy
is size + weight, not a second typeface.

| Role            | Size / weight / line-height | Family  |
| --------------- | --------------------------- | ------- |
| `heading1`      | 36 / w800 ExtraBold / 40    | Poppins |
| `heading2`      | 24 / w700 Bold / 32         | Poppins |
| `heading3`      | 20 / w600 / 28              | Poppins |
| `heading4`      | 16 / w600 / 24              | Poppins |
| `subtitle`      | 14 / w400 Regular / 20      | Poppins |
| `bodyBase`      | 16 / w400 / 24              | Poppins |
| `bodyBaseMedium`| 16 / w500 / 24              | Poppins |
| `bodySm`        | 14 / w400 / 20              | Poppins |
| `bodySmMedium`  | 14 / w500 / 20              | Poppins |
| `bodyXs`        | 12 / w400 / 16              | Poppins |
| `bodyXsMedium`  | 12 / w500 / 16              | Poppins |
| `textFieldBase` | 16 / w400 / 24              | Poppins |
| `linkSm`        | 14 / w500 / 20 · underline  | Poppins |

**Tracking:** Display **-2.2%**, Heading 1–2 **-1%**, body / UI / Subtitle **0**.

**Default ink (Figma sample → kit):**

| Role | Color token | Dart |
| ---- | ----------- | ---- |
| Display, Heading 1–4, Body base*, Body sm medium, Button | `foreground/base` | `colors.foreground` / `onSurface` |
| Subtitle, Body sm, Body xs (w400) | `foreground/muted` | `colors.muted` |
| Link base / Link sm | `foreground/link` | `colors.foregroundLink` |
| Text field base / sm | `field/foreground` | `colors.fieldForeground` |

`*` Body sm medium stays primary ink (card/nav titles). Material
`textTheme.bodyMedium` (Body sm) and `bodySmall` (Body xs) default to muted;
`labelMedium` (Body xs medium), `titleSmall` and `labelLarge` keep `onSurface`
for emphasized 12–14px UI.

### Semantic app roles (prefer these on screens)

| Role           | Maps to                        | Use for                                   |
| -------------- | ------------------------------ | ----------------------------------------- |
| `pageTitle`    | `heading2` (18–24/w700)        | Screen / page titles, auth titles         |
| `pageSubtitle` | `subtitle` (14/w400)           | Supporting line under page titles (auth)  |
| `sectionTitle` | `heading4` (16/w600)           | Detail / section headings                 |
| `sectionLabel` | 12 / w600 / letter-spacing 0.5 | Uppercase group eyebrows (lists, settings)|
| `rowTitle`     | `bodySmMedium` (14/w500)       | Dense nav / sidebar row titles            |
| `rowValue`     | `bodySm` (14/w400)             | Dense row values                          |
| `listRowTitle` | `bodyBaseMedium` (16/w500)     | Content list row label (Settings, inbox)  |
| `listRowValue` | `bodySm` (14/w400)             | Content list row value (apply muted ink)  |
| `cardTitle`    | `bodySmMedium` (14/w500)       | Card titles                               |
| `cardSubtitle` | `bodyXs` (12/w400)             | Card secondary text / preview             |
| `bodyLarge`    | `bodyBase` (16/w400)           | Detail paragraphs (Kanban detail, chat)   |
| `caption`      | `bodyXs` (12/w400)             | Captions, timestamps, helper text           |
| `titleLarge`   | `heading3` (20/w600)           | Modal / bottom-sheet shell title          |

Access the semantic roles via `context.kasyTextTheme.*` (colourless — apply
colour with `.copyWith`). Access Material slots via `context.textTheme.*`
(colour already applied). Raw scale roles that expose **statics**
(`KasyTextTheme.heading1`, `bodyBase`, …) are the **desktop baseline for
specs/docs** (size/weight only; letter-spacing `0` on those getters).
`subtitle` has **no** static getter; use `context.kasyTextTheme.subtitle`.
Live UI should prefer `context.*` so text scales and tracking from
`KasyTextTheme.build` apply (Display **-2.2%**, Heading 1–2 **-1%**).

Material → role mapping: `headlineMedium = heading2`, `headlineSmall =
titleLarge = heading3`, `titleMedium = heading4`.

The chrome/app-bar title is **w700** (not w900). Black weights read as heavy and
fight the rest of the UI.

### Responsive type — `KasyTypeScale` (`lib/core/theme/type_scale.dart`)

Not a blind multiplier: **every role declares an explicit size per breakpoint**
(the way Figma variables / modes do). The professional pattern — **headings are
largest on desktop and step down on smaller viewports** (a 36px title would feel
huge and wrap badly on a phone), while **body and labels stay constant** for
stable reading everywhere. Desktop is the authored reference.

| Role (example) | Mobile `< 768` | Tablet `768–1023` | Desktop `>= 1024` |
| -------------- | -------------- | ----------------- | ----------------- |
| `displayLarge` | 36 / 40        | 46 / 52           | 57 / 64           |
| `displayMedium`| 32 / 37        | 38 / 44           | 45 / 52           |
| `heading1`     | 28 / 31        | 32 / 36           | 36 / 40           |
| `heading2`     | 18 / 26        | 21 / 28           | 24 / 32           |
| `heading3`     | 20 / 28        | 20 / 28           | 20 / 28           |
| `subtitle`     | 14 / 20        | 14 / 20           | 14 / 20           |
| `bodyBase`     | 16 / 24        | 16 / 24           | 16 / 24           |
| `bodySm`       | 14 / 20        | 14 / 20           | 14 / 20           |

Values are `size / line-height` (logical px), matching Figma Text Styles.

**Tracking:** Display **-2.2%**, Heading 1–2 **-1%**, body / UI / Subtitle **0**.

`ResponsiveTextTheme` (innermost wrapper in `main.dart`) reads the viewport
width, picks the breakpoint and republishes a `Theme` whose Material `textTheme`
and `KasyTextTheme` extension use that breakpoint's sizes. Every
`context.textTheme.*` and `context.kasyTextTheme.*` inherits it — **no screen
does anything**; desktop is a pass-through (the authored baseline). Component
themes (buttons, inputs) keep their calibrated sizes on purpose. To re-tune the
whole app, edit the numbers in `KasyTypeScale` — nothing else. The live ramp is
visible under **Design System → Typography** (tabs per breakpoint).

**Web render scale (`WebViewportScale`, `lib/core/web_viewport_scale.dart`).** A
separate concern from type sizes, sitting in a layer ON TOP of the responsive
system — it does NOT replace breakpoints, the type ramp or spacing (turn it off
and the app is still fully responsive, just larger). Flutter web renders ~10%
larger than an equivalent native/HTML app at 100% zoom, **at every width**. So the
whole web app — phone, tablet and desktop — is scaled to `0.93` (~7%; a true
viewport scale, so layout, scrolling and hit-testing stay correct), making web
read close to the native baseline with **no size jump across breakpoints**. `0.90`
fully undoes the 10% but reads small on a monitor (native sizes are tuned for a
phone held close), so `0.93` (a touch past the midpoint, still short of a full
undo) is the calibrated default. Two knobs:
`kWebViewportScaleEnabled` (master on/off — `false` makes web render at native
`1.0`) and `kWebViewportScale` (the value). Two things are never scaled, both at
native `1.0`: **native** itself (gated on `kIsWeb`, so iOS/Android/macOS/Windows
respect the system text-size for accessibility) and the **in-app device preview**
(it simulates a native device, so the scale is skipped while it is on — a
developer previewing a phone sees the native size). On desktop a high-OS-scale
screen compensates below the cap, keyed off the screen width (see
`kWebViewportScaleTargetWidth`).

---

## Icon sizes — `KasyIconSize` (`lib/core/theme/icon_sizes.dart`)

| Token       | px | Token alias  | = |
| ----------- | -- | ------------ | -- |
| `xxs`       | 12 | `rowLeading` | `lg` (20) |
| `xs`        | 14 | `rowTrailing`| `sm` (16) |
| `sm`        | 16 | `chrome`     | `lg` (20) |
| `md`        | 18 | `inline`     | `md` (18) |
| `lg`        | 20 | | |
| `xl`        | 24 | | |
| `xxl`       | 28 | | |
| `display`   | 36 | | |
| `hero`      | 72 | | |

Never pass a numeric `size:` to `Icon`; use a token so re-scaling is one edit.

---

## Spacing — `KasySpacing` (`lib/core/theme/spacing.dart`)

`xs 4 · sm 8 · smd 12 · md 16 · lg 24 · xl 32 · xxl 48 · xxxl 64`

Full raw ruler (design-system docs only): `0 · 4 · 8 · 12 · 16 · 24 · 32 ·
40 · 48 · 56 · 64 · **75** · 80 · 96 · 128 · 160` (`KasySpacing.space0` … `space160`;
`75` = `authContentTop` / Figma `spacing/auth-top`).

`pageHorizontalGutter = md (16)` · `pageVerticalGutter = sm (8)` ·
`chromeGap` / `belowChromeContentGap = 20`.

## Radius — `KasyRadius` (`lib/core/theme/radius.dart`)

Semantic: `xs 4 · sm 8 · md 12 · lg 16 · xl 24 · full 999`.
`KasyCard` defaults to `xl` (24).

Rounded scale (HeroUI / Tailwind): `rounded-none 0 · rounded-xs 2 ·
rounded-sm 4 · rounded-md 6 · rounded-lg 8 · rounded-xl 12 · rounded-2xl 16 ·
rounded-2.5xl 20 · rounded-3xl 24 · rounded-4xl 32 · rounded-full 9999`.
Semantic aliases map into this scale (`xs` → `rounded-sm`, `sm` → `rounded-lg`,
…); `full` (999) is **not** `rounded-full` (9999).

---

## Colours — `KasyColors`, via `context.colors.*`

Always pull colours from `context.colors.*` so light/dark resolve automatically.
Key tokens: `primary`, `onPrimary`, `surface`, `onSurface`, `onBackground`,
`muted`, `fieldLabel`, `outline`, `error` / `onError`, `success`, `warning`,
`surfacePrimarySoft`, `surfaceNeutralSoft`, `surfaceErrorSoft`.

Accent / primary: light `#0553B1`, dark `#2563EB` (Figma `brand/primary/base`,
Sign In CTA bind → `context.colors.primary`). The kit is **primary-led**: filled
buttons, focus rings, selected chips, progress, spotlight accents and real links
all use primary. Dark links use `text/link` `#4BA3FF` via `foregroundLink` (a
bit lighter than the CTA). There is no brand secondary colour token. Zinc
supporting fills are `surfaceSecondary` / `backgroundSecondary` (Figma
`surface/secondary`, `background/secondary`). Secondary text actions ("Not now")
use `onSurface`, not primary/link blue. Rebranding is a token swap in
`KasyColors` (and splash colors in `pubspec.yaml`). Disabled state: blend the
colour toward the surface (opaque), never raw opacity.

**Any colour that feeds an `AnimatedContainer`/`Color.lerp` transition must
stay fully opaque, same as disabled state above.** `Color.lerp` interpolates
alpha and hue independently — cross-fading a translucent tint (e.g. a
selected chip's `accent.withValues(alpha: 0.1)`) into an opaque colour of a
different hue (e.g. `surfaceNeutralSoft`) sweeps alpha through mid-values
while the hue is still close to the tint, so for a few frames the result
reads more saturated than either resting state — a flash/flicker too fast for
a screenshot to catch but very visible live. Use `accent.tintOn(context.colors.surface,
0.1)` (extension in `colors.dart`) instead of `.withValues(alpha:)` for any
colour used in an implicit animation — found in `KasySelectableChip`'s
priority-chip selection (Kanban, 2026-07-21).

**Stacked surfaces (the elevation ladder).** Two common nestings (do not
collapse them into one “surface is always the lighter raised step” rule):

1. **Page / group → elevated card:** page `background` → group
   `backgroundSecondary` / `surfaceSecondary` → item `KasyCard` elevated =
   `surface` + shadow. Light: `surface` (`#FFFFFF`) is brighter than the zinc
   group. Dark: `surface` (`#18181B`) matches `backgroundSecondary` and is
   **darker** than `surfaceSecondary` (`#27272A`); the raise comes from shadow
   / `surfaceElevated`, not from `surface` being lighter than the group.
2. **Sheet / dialog shell → inner panel:** shell paints `surface`; inner
   cards use `KasyCardVariant.filled` (`surfaceSecondary`, no elevated shadow).
   Never paint `elevated` with a forced `surfaceSecondary` colour (keeps the
   heavy shadow and looks muddy in light).

Don't invert the ladder (card = page `background`, column = card colour). The
kanban board is the reference for nesting (1).

**Sheets extra.** `KasyDragHandle` already includes bottom breathing room
(`KasySpacing.md`); do not sit the first title/card flush under the grab bar.

**Switch on filled cards.** Off-track defaults to `neutralHover`, not `neutral`:
in dark mode `neutral` and `surfaceSecondary` are the same zinc (`#27272A`), so
an off switch painted with `neutral` vanishes into a filled card.

**Priority tones** (`priorityLow` · `priorityMedium` · `priorityHigh` ·
`priorityUrgent`): readable-by-theme accents for task priority (green → amber →
orange → red). Each resolves to HeroUI's darker tone in light mode and the vivid
base in dark mode, so it stays legible as a label on a soft fill. Pass to
`KasyStatusTag(color: …)` so a priority pill reads identically to a table status
pill (the kanban card and the form selector both use these).

---

## Components

### `KasyButton`
Sizes (height): `small 40` · `medium 45` (default) · `large 54`. Radius
`KasyRadius.xl` (24; override per call site). Variants: primary, secondary,
tertiary, destructive, destructiveSoft, inverse, link, soft, neutral, outline,
ghost.

**Press / hover feedback (web):** default `pressEffect` is `both` (scale +
veil). On native the default is `depth`. The veil tint is resolved automatically:

1. visible `borderColor` (or variant border) → same hue at ~10% alpha in light
   and ~4% in dark;
2. else opaque `backgroundColor` → slightly darker/lighter fill family;
3. else label `foregroundColor` at ~10% light / ~4% dark (on-brand buttons).

Pass `pressOverlayColor` when you need an exact veil (include alpha). Live
demo: **Components → Button → Custom palette hover** (cream + gold Figma pair).

**Custom colours from Figma:** prefer a matching `variant` when
possible. When you override `backgroundColor` / `borderColor`, you do not need
to hand-tune hover unless the auto tint still feels off.

### `KasyTextField`
Canonical single-line height: **`singleLineHeight = 45`** (drives the field's
vertical padding so the box itself is exactly that tall on every platform — web
density is normalised to `standard`). Matches the medium button.

Variants (live demos: **Components → TextField → Variants**; this section is the
**when-to-use rule**, not a visual catalog):

- **primary** — surface fill + hairline border + soft shadow (floats; good on a
  bare background).
- **secondary** — elevated fill (contrasts on a card) + border, no shadow.
  Resting fill: `kasyElevatedSurfaceInputFill()` → `surfaceSecondary`
  (Figma `surface/secondary`: `#F4F4F5` light / `#27272A` dark).
- **tonal** — `surfaceSecondary` fill, no border, no shadow (chrome /
  header search; Figma Home Search).
- **flat** — surface fill + soft field-flat border, **no shadow**. For fields
  sitting on a same-coloured card. Border comes from
  `KasyShadows.inputFieldFlatBorder` → `colors.borderFieldFlat`
  (Figma `border/field-flat`).
- **embedded** — transparent, no border, no shadow.

**Surface contract**

| Where the field sits | Variant |
| -------------------- | ------- |
| Page / scaffold | `primary` (default) |
| Same-colour card (auth, settings group) | `flat` |
| Inside `KasyBottomSheet` or `KasyDialog` | `primary` — the shell elevates fill automatically |
| Explicit elevated look without relying on Theme | `secondary` |

Auth screens (sign in / sign up / recover) use **flat** on every breakpoint.
Settings **name** on tablet/desktop uses **inline `flat`** (Edit toggles the
field in the row; no modal). Mobile name edit uses the bottom sheet.

A custom `contentPadding` opts a field out of the 45 lock (e.g. the compact
header search). Use `forceFocusBorder` to show the "active" focused border from
state without taking real focus (composite triggers like the date picker).

### `KasyBottomSheet` / `KasyDialog`

Both are modal surfaces for forms and confirmations. **`KasyBottomSheet`** is
always a bottom sheet on every breakpoint (no morphing to a side panel).

**Desktop form pattern** (Criar senha, Enviar feedback, Kanban): same form
widget; desktop opens **`showKasyBlurDialog` + `KasyDialog`**, mobile/tablet
opens the sheet. **`KasyDialog`** on desktop (>= 1024) uses roomier padding,
wider max width (480), and larger gaps between title, body and actions.

Both shells wrap `KasyTextField.primary` bodies with
`kasyElevatedSurfaceInputFill()` so fields stay visible on the surface-coloured
card without setting `variant: secondary` at every call site.

**Inline single-field edit** on a settings page (desktop) does not need a dialog:
keep the field in the row (`flat`), with Edit / Cancel / Save in the section
header.

### `KasyTextArea`
Multi-line sibling of `KasyTextField`; same variants (primary / secondary / flat
/ tonal / embedded). Height grows with content (not locked to 45).

### `KasyDatePicker`
Reuses `KasyTextField` as a read-only trigger, so it inherits the field look and
the 45 height. The "open" border is painted from state (`forceFocusBorder`), so
a mouse click never doubles the keyboard focus ring and the border never drops
while the calendar is open.

### `KasySelectableChip`
Flicker-free pill for single/multi-choice option groups (priority pickers,
filters, tag selectors). At rest it looks like `KasyStatusTag` (tinted pill +
optional dot + label); `selected` drives a soft accent fill. Pass `color:` (e.g. a
priority token) so the chosen chip matches its display tag. **Use this instead of
a hand-rolled chip** — it bakes in the no-blink recipe (fixed label weight so the
`Wrap` never reflows, animated dot/label colour, border fading to `alpha 0` not
`Colors.transparent`); in a list give each chip a stable `Key`.

### `KasyCard`
Elevated surface panel, radius `KasyRadius.xl` (24). The base for screen content.

### `KasyScreen`
Opt-in screen scaffold. A new page wrapped in it gets the internal-screen
contract for free: page background, a centred content column capped at
`maxContentWidth` (~600), default padding horizontal `pageHorizontalGutter`
(16) and vertical `KasySpacing.lg` (24), scroll handling and an optional
`KasyCard`. It is a thin convenience over `Scaffold` (same `appBar` /
`floatingActionButton` / `bottomNavigationBar` slots), so it never blocks a
screen that needs bespoke chrome.

```dart
KasyScreen(
  appBar: KasyAppBar.root(title: 'Settings'),
  card: true,
  child: Column(children: [...]),
)
```

---

## Internal-screen contract

New internal screens follow the sidebar / Home ruler:
- Body text 14, icons 20 (`KasyIconSize.lg`), titles 16 / w600 (`sectionTitle`
  → `heading4`).
- `KasyCard` default radius `KasyRadius.xl` (24); use `lg` (16) only when the
  screen intentionally tightens corners. Content width contained (~600), no
  oversized boxes.
- Use `KasyButton`, `showKasyConfirmDialog`, `showKasyToast` — not raw Material.
- Haptics only on native (`if (!kIsWeb)`); "hide chrome on scroll" only on the
  phone breakpoint (`width < 768`).

**Content width — two tiers (width follows content type):**
- *Read / form screens* (Settings, Notifications, Reminder) → **contained + centred**
  at `kKasyContentMaxWidth` (600, in `core/widgets/responsive_layout.dart`). With the
  overlay pattern, set `KasyOverlayScaffold(maxContentWidth: kKasyContentMaxWidth)`
  (centres the column on desktop, keeps the 16 gutter on mobile); with `KasyScreen`
  it is the default. Long lines and lone controls read badly stretched, so cap them.
  The Settings desktop master/detail centres the **nav + detail as one unit**.
- *Browse / feed / dashboard screens* (Home) → **full width + the 16 gutter, on
  purpose** — width helps a grid/feed. Do **not** contain these.
- Every tier shares the same family (16 gutter, `KasyCard`, sidebar + web header) —
  only the max width changes. To mirror: copy **Settings / Notifications** for a
  contained screen, **Home** for a full-width one.

---

## Browser QA / Semantics contract

Flutter web paints to a canvas. Browser AI agents (and screen readers) only find
controls through the **semantics tree**. Every interactive kit component must
expose a stable, accurate name. The agent ritual lives in `AGENTS.md`; **this
section is the component contract** (what to build).

### Rules

1. **Stable name.** Every tappable / editable control needs `label`,
   `semanticLabel`, or `tooltip` (icon-only). Prefer i18n strings, not
   placeholders alone.
2. **Icon-only.** `tooltip` or `semanticLabel` is **required** and must match
   the action (e.g. drawer → `MaterialLocalizations.openAppDrawerTooltip`, not
   a sibling string like "Settings").
3. **Text inputs (`KasyTextField`, `KasyTextArea`, OTP).** Wrap with
   `Semantics(label: …)` only. Do **not** set `textField: true` on that wrapper:
   `TextField` already contributes the editable node. A second `textField: true`
   creates a mirror `<input>` agents type into without reaching Flutter.
4. **Composites (DropDown, DatePicker, TimePicker, NumberField).** The trigger is
   `Semantics(button: true, label: …, onTap: …)` (or named stepper buttons). The
   read-only `KasyTextField` inside sits under `ExcludeSemantics` (+
   `IgnorePointer` when it must not steal focus) so agents do not see a fake
   textbox.
5. **Toggles.** Checkbox / Switch / Radio expose `Semantics` with
   `checked` / `toggled` / `selected` plus a label (or `semanticLabel`).
6. **No mute `GestureDetector` / `InkWell`.** If it is tappable, wrap with
   `Semantics(button: true, label: …)` (or use a kit component that already does).
7. **Page chrome on phone.** Put [KasyAppBar] in a **Stack overlay on top of the
   body** (same as [KasyOverlayScaffold]), not in a `Column` above `Expanded`.
   Column + Flutter web device-preview drops orb Semantics (mute hamburger /
   theme). Demo ritual: Components → **Browser QA**.

### Anti-patterns

| Avoid | Why |
| ----- | --- |
| `Semantics(textField: true)` around `TextField` | Mirror input; typing never reaches Flutter |
| `IgnorePointer` + `KasyTextField` **without** `ExcludeSemantics` | Snapshot shows a textbox; click focuses, panel never opens |
| Icon button without `tooltip` / `semanticLabel` | Mute control in the agent snapshot |
| Label only as a sibling `Text` (no Semantics / decoration) | `setTextIn('Label', …)` cannot find the field |
| `Column` → `KasyAppBar` → `Expanded` (phone/page chrome) | Mute hamburger / theme orbs under device-preview |

### New `KasyXxx` checklist

- [ ] Interactive? Then name via `label` / `semanticLabel` / `tooltip` (i18n).
- [ ] Correct Semantics role (`button`, editable via TextField, `toggled`, …).
- [ ] No duplicate `textField: true`.
- [ ] Composite triggers use `button` + `ExcludeSemantics` on visual field.
- [ ] Demo in the Components preview registry with a stable accessible name.
- [ ] Call sites for icon-only chrome use a faithful tooltip.
- [ ] Phone screens with a page bar use Stack overlay, not Column above Expanded.

Runtime helpers (debug/profile web): `SemanticsBinding.ensureSemantics()` and
`window.__kasyBrowserQa.setTextIn(label, text)` — see `AGENTS.md` Browser QA.

Guard: `make check-browser-qa` (or `dart run scripts/check_browser_qa_semantics.dart`)
ratchets anti-patterns (`Semantics(textField: true)`,
`IgnorePointer`+`KasyTextField` without `ExcludeSemantics`,
`Column`+`KasyAppBar`+`Expanded`, `KasyChromeOrbIconButton` without `tooltip`).
Preview: Components → **Browser QA**.
---

## Keeping it consistent — the guard-rail (optional)

`tool/design_check.dart` is a plain Dart script that flags a **feature screen**
reaching around the design system (raw `fontSize`, raw `Color`, raw Material
widgets, literal icon sizes). The design system's own code (`lib/components`,
`lib/core`, `lib/main.dart`) is out of scope — that is where the primitives are
built.

```bash
dart run tool/design_check.dart
```

It is **opt-in and non-blocking by default**: nothing runs it automatically (not
wired into `flutter analyze`, builds or git hooks). Run it locally or add it to
CI to turn the convention into a gate. It has no extra dependency — read it, tune
the allowlists at the top, or delete it. For a deliberate one-off, put
`// design-check: ignore` on the line.

This is intentionally not a cage: a project built with the kit can relax or drop
it freely, and rebranding is still a token change (colours / type) in one place.

---

## Figma variable map

Master file: [Kasy Design System](https://www.figma.com/design/S083trj2ctFrEFNjcavtpC)
(`fileKey` `S083trj2ctFrEFNjcavtpC`), page **01 Tokens**. Collections 01–06
mirror `lib/core/theme/`. Use this table for AI rebrand / 1:1 sync. Prefer Figma
**names** here; hex values are kept in sync with `KasyColors.light()` /
`.dark()` (parity closed 2026-07-12). Rebrand: update Figma Variables first,
then run Step 1 of `docs/figma-workflow.md`.

### Colours (collection **Kasy Colors**)

| Figma | Dart | Notes |
| ----- | ---- | ----- |
| `brand/primary/base` (+ hover / soft via getters) | `context.colors.primary` | Brand CTA + sparse accents. Light `#0553B1`, dark `#2563EB`. Kit is **primary-led** (no brand secondary). |
| `brand/primary/soft-fill` | `primarySoftFill` | Opaque soft for stacked avatars/chips (`tintOn(surface, 0.15)`). Not the translucent `primarySoft`. |
| `brand/primary/soft-foreground` | `primarySoftForeground` | Light `#042B59`, dark `#93C5FD` |
| `neutral/base` · `hover` · `foreground` | `neutral` · `neutralHover` · `neutralForeground` | Neutral backbone |
| `success/*` · `warning/*` · `danger/*` · `info/*` | matching `success*` / `warning*` / `danger*` / `info*` | `*/soft` and `*/soft-hover` use alpha (15% / 20%). Opaque `*/soft-fill` → `*SoftFill` via `tintOn(surface, 0.15)` (avatars/chips) |
| `text/primary` · `secondary` · `tertiary` · `segment` · `on-overlay` · `link` · `inverse` | `foreground*` / `foregroundMuted` / `foregroundTertiary` / `foregroundLink` … | `text/link` dark `#4BA3FF` (lighter than CTA) |
| `background/base` · `secondary` · `tertiary` · `inverse` | `background*` | Splash colour = `background/base` |
| `surface/base` · `secondary` · `tertiary` · `elevated` · `transparent` | `surface*` / `surfaceElevated` | `surface/elevated` dark = zinc/800 `#27272A` (not the same as `overlay`) |
| `field/*` | `fieldBackground*` · `fieldPlaceholder` · … | Resting `field/border` stays transparent; visible stroke = `border/field*` |
| `separator/*` | `separator*` | |
| `border/default` | `border` / `outline` | Solid chrome |
| `border/soft` | `borderSoft` | Overlay chrome (Dialog/Menu/Toast/DropDown/social) — `#DEDEE0` @ 0.26 / `#3F3F46` @ 0.22 |
| `border/orb` | `borderOrb` | AppBar chrome orb outline — `#DEDEE0` @ 0.14 / `#3F3F46` @ 0.17 |
| `border/elevated` | `borderElevated` | Elevated card; transparent in dark |
| `border/field` | `borderField` | TextField / DropDown trigger resting — `#DEDEE0` @ 0.20 / `#3F3F46` @ 0.15 |
| `border/field-flat` | `borderFieldFlat` | TextField flat/auth |
| `border/subtle` | `borderSubtle` | Quiet opaque edge (`#F4F4F5` light / `#27272A` dark) |
| `border/strong` | `borderStrong` | Calm chrome (`#DEDEE0` @ 0.40 / `#3F3F46` @ 0.45) |
| `overlay/base` · `segment/base` · `backdrop/base` | `overlay` · `segment` · `backdrop` | |

### Spacing (collection **Kasy Spacing**)

| Figma | Dart |
| ----- | ---- |
| `spacing/xs` … `spacing/xxxl` | `KasySpacing.xs` … `xxxl` |
| `spacing/0` · `spacing/40` · `56` · `80` · `96` · `128` · `160` | `KasySpacing.space0` · `space40` … `space160` |
| `spacing/auth-top` (75) | `KasySpacing.authContentTop` |
| `spacing/page-x` · `spacing/page-y` | `KasySpacing.pageHorizontalGutter` · `pageVerticalGutter` |
| `spacing/chrome-gap` | `KasySpacing.chromeGap` (= `belowChromeContentGap`, 20) |

### Radius (collection **Kasy Radius**)

| Figma | Dart |
| ----- | ---- |
| `radius/rounded-xs` … `rounded-4xl` · `rounded-full` | `KasyRadius.roundedXs` … `rounded4xl` · `roundedFull` |
| `radius/xs` · `sm` · `md` · `lg` · `xl` | `KasyRadius.xs` … `xl` (aliases into rounded-*) |
| `radius/full` (999) | `KasyRadius.full` (not `roundedFull` 9999) |

### Shadows / effects (collection **Kasy Shadows** + Effect Styles)

Figma 01 Effect Styles are the numbers. Dart helpers are thin wrappers. The
Design System Effects tiles call the **same** helpers as the components.

| Component | Dart | Figma Effect |
| --------- | ---- | ------------ |
| `KasyCard` elevated / spotlight | `cardElevated` / `surfaceOf` | `shadow/surface` |
| `KasyCard` filled | none (`cardFilled` → empty) | flat `surface/secondary` fill |
| `KasyTextField` primary | `inputField` (flat auth = stroke only) | `shadow/field` |
| `KasySwitch` thumb | `switchControlOf` | `shadow/switch` |
| `KasyTabs` primary pill | `tabOf` | `shadow/tab` |
| Menu · Dropdown · Popover · bell | `overlayPanel` | `shadow/overlay` |
| Inner inset | `innerShadowOf` (`BlurStyle.inner`) | `shadow/inner` |
| Frosted chrome / modal scrim | `frostedBlur` / `modalScrimBlur` | `blur/frosted` · `blur/scrim` |
| Keyboard focus | `focusRing` / `focusRingField` | `focus/ring` · `focus/ring-field` |

Stacks are light: `surfaceOf` / `fieldOf` use field + component alphas Light
`0.04` / `0.03`, Dark `0.06` / `0.045`. Do not reintroduce the old heavy
`component` / `inputField` single-shadow look.

### Typography

| Figma | Dart |
| ----- | ---- |
| `font-size/text-field` · `line-height/text-field` | `KasyTextField.fieldFontSize` / `fieldLineHeight` (16 / 24) · `textFieldBase` |
| `--font-display` · `--font-body` | Poppins via `texts.dart` |
| Tokens · Typography ramp (Small / Medium / Large) | `KasyTypeScale` + `KasyTextTheme` |

### Icon size (collection **06 Icon Size**)

| Figma tile | Dart |
| ---------- | ---- |
| icon-12 … icon-72 | `KasyIconSize.xxs` … `.hero` |
| Semantic row / chrome | `rowLeading` · `rowTrailing` · `chrome` · `inline` |

### Branding assets (not variables)

| Asset | Use |
| ----- | --- |
| `assets/branding/logo-{light,dark}.png` | In-app wordmark (`KasyBrandLogo`) |
| `assets/branding/splash-logo-{light,dark}.png` (+ android12) | Native / web splash only |
| `assets/branding/app-icon.png` · `favicon.png` | Launcher / store (`kasy icon`). Separate art from the transparent wordmark. |

### Paywalls (subscription screens)

Four layouts via `PaywallFactory` (`solo` · `compare` · `trial` · `unlock`).
They are **intentional product templates**, not unfinished DS work.

| Layout | Role |
| ------ | ---- |
| **Unlock** | The only paywall that follows kit tokens (`context.colors.*`). A Figma → `KasyColors` rebrand updates it automatically. |
| **Solo · Compare · Trial** | Creative skins that **stay as shipped**. They do not pick up the kit rebrand. Customize only if the client wants to redesign that specific template (`paywall_*_palette.dart`). |

---

## Changelog

### 1.2.7 — 2026-07-22
- Restored opaque `*/soft-fill` tokens (Figma Variables + Design System tiles).
  Dart: `primarySoftFill` / `successSoftFill` / `warningSoftFill` /
  `dangerSoftFill` / `infoSoftFill` via `base.tintOn(surface, 0.15)`. Use for
  overlapping avatars/chips; keep translucent `*Soft` for overlays. Rebrand:
  change base + surface; soft-fill follows at runtime.

### 1.2.6 — 2026-07-18
- Browser QA / Semantics contract: stable names, no duplicate `textField: true`,
  composite triggers as `button` + `ExcludeSemantics`, checklist for new
  `KasyXxx` components. Agent ritual stays in `AGENTS.md`.

### 1.2.5 — 2026-07-18
- Closed Figma ↔ kit colour gaps for rebrand: `info/*`, `text/tertiary`
  (`foregroundTertiary`), `surface/elevated` (`surfaceElevated`),
  `border/subtle` · `border/strong`. Page **02 Components** paints/radii/effects
  bound to Variables (Google/Facebook brand marks stay static).
- Doc parity pass vs code: stacked-surface ladder (light vs dark), sheet
  nesting, shadow alphas, TextField flat border token, Button veil dark alpha,
  `KasyScreen` padding, type statics / ink notes.

### 1.2.4 — 2026-07-13
- Parity hammer documented: Figma 01 owns token numbers; theme mirrors; DS +
  components consume; Figma 02/03 bind. Overlay = Menu · Dropdown · Popover ·
  bell.
- Shadows aligned to Figma Effect Styles (`shadow/surface`, `field`, `switch`,
  `tab`, `overlay`, `inner`). Card/Field use light stacks; no orphan heavy
  `inputField` / `component` look for those roles.

### 1.2.3 — 2026-07-13
- Removed fake brand **Secondary** colour role: no `KasyColors.secondary`, no
  Secondary block on the Design System screen. Zinc fills stay under
  `surfaceSecondary` / `backgroundSecondary` (Figma `surface/secondary`).
- Design System Spacing: one ruler (0–160), no duplicate Named scale.
- Effects Inner preview: inset contour (Figma), not a top-down gradient.
  Dropped unused `KasyShadows.blurRadius` / `backdropBlurRadius`.
  Inner now uses `BlurStyle.inner` (`KasyShadows.innerShadow`). Switch /
  overlay alphas aligned to Figma; DS labels Card / Dropdown match Tokens page.
  Effects Shadows tiles now mirror runtime helpers (`cardElevated`,
  `inputField`, `switchControl`, `tabOf`, `overlayPanel`).
- Design System screen: chrome borders `border-soft` / `border-elevated` /
  `border-field` / `border-field-flat` (Figma names). Paywall contract clarified
  (only Unlock follows kit rebrand; Solo/Compare/Trial stay creative skins).

### 1.2.2 — 2026-07-13
- Figma ↔ Kit colours: `brand/primary/base` dark `#2563EB` (light stays
  `#0553B1`); `text/link` dark `#4BA3FF`; `primarySoftForeground` dark
  `#93C5FD`. No `brand/secondary` (primary-led). New chrome tokens
  `borderSoft` / `borderElevated` / `borderField` / `borderFieldFlat` wired into
  `KasyShadows` field borders, `KasyCard`, and `KasyButtonVariant.secondary`.

### 1.2.1 — 2026-07-12
- Figma ↔ Kit parity closed: colors (incl. neutrals), spacing, radius, icon
  sizes, typography ramp and shadows match `lib/core/theme/`. Variable names
  follow this doc (`primary/base` ↔ `primary`, etc.). Removed Figma-only
  leftovers (`secondary/soft*`, `border/card`, `border/social`, extra field
  tokens, `backdrop/dim`, opaque `*-soft-fill*` swatch vars). `primaryHover` =
  `Color.lerp(primary, white, 0.14)` → light `#286BBC`, dark `#4479EE`.
- Design System screen + Figma Tokens page list the same roles (Secondary =
  `base` only; Form field = seven Kit tokens).

### 1.2.0 — 2026-06-24
- `KasyDialog`: desktop layout (padding, width, header/footer gaps) and elevated
  input fill via `kasyElevatedSurfaceInputFill()` (parity with `KasyBottomSheet`).
- `KasyTextField`: shared `kasyElevatedSurfaceInputFill()` helper; surface
  contract documented (primary / flat / secondary + modal shells).
- Modal form pattern documented: dialog on desktop (>= 1024), sheet on
  mobile/tablet; inline single-field edit stays on the page.

### 1.1.0 — 2026-06-11
- `KasyScreen` scaffold added (opt-in internal-screen contract).
- Design guard-rail added (`tool/design_check.dart`), opt-in / non-blocking.
- Feature layer brought to a green baseline against the guard-rail (deliberate
  exceptions marked with `// design-check: ignore`).


### 1.0.0 — 2026-06-11
- Icon sizes centralised into `KasyIconSize`; product migrated off numeric sizes.
- Semantic typography roles added (`pageTitle`, `sectionTitle`, `sectionLabel`,
  `rowTitle`, …).
- `KasyTextField`: `flat` variant + `forceFocusBorder`; single-line height
  locked to **45** via derived padding (platform-independent); flat border role
  `inputFieldFlatBorder` (visible in both themes, subtle).
- `KasyButton` medium height aligned to **45**.
- `KasyTextArea` gained the variant set.
- App-bar title weight `w900 → w700`; onboarding titles `28/26 → 24` (page-title
  scale); notifications group eyebrow → `sectionLabel`.
