# UX — Lucian Labs behaviour contract

The look files say what it looks like. This says **how it behaves**: states,
timing, feedback, density, responsiveness, and what happens when things are
empty, slow, or broken.

Platform-agnostic on purpose. These apps ship as web, SwiftUI, and native iOS
from one brand, and the next one might be Kotlin, SDL, or a game loop. So this
file specifies *behaviour, numbers, and curves* — never syntax. Read it
alongside `AGENT.md` and whichever look you picked.

---

## 0 · Reading this on your platform

Everything below is written so it can be derived on any engine. Three
conventions make that possible.

### Units

All sizes are in **density-independent units**: 1 unit = 1 logical pixel at 1×
scaling = 1pt on Apple platforms = 1dp on Android. Multiply by the display
scale factor yourself. Never interpret a number here as a physical pixel.

### Easing, defined numerically

Named curves, given as cubic Bézier control points `(x1, y1, x2, y2)` so any
engine can implement them — including one where you interpolate by hand each
frame.

| Name | Control points | Feel | Use |
| --- | --- | --- | --- |
| **standard** | 0.25, 0.1, 0.25, 1 | gentle in and out | hover, colour, focus |
| **decelerate** | 0.22, 1, 0.36, 1 | fast start, long soft landing | a value arriving at its number |
| **emphasised** | 0.4, 0, 0.2, 1 | slow out, slow in | a screen changing identity |
| **sharp** | 0.7, 0, 0.3, 1 | abrupt both ends | something leaving decisively |
| **spring** | damping ≈ 0.8, response ≈ 0.2s | overshoots slightly, settles | press feedback (Punch Clock only) |

If your engine has no Bézier evaluator: `decelerate` ≈ `1 - (1-t)³`,
`emphasised` ≈ smoothstep, `standard` ≈ smoothstep. These are close enough that
no user will tell the difference; a *linear* tween is not, and reads as cheap.

### Frame independence

Every duration here is wall-clock milliseconds. In a render loop, drive
animation from elapsed time, never from frame count — the same transition must
take 150ms at 30fps and at 144fps.

### When your platform lacks a concept

| Concept | If the platform has it | If it doesn't |
| --- | --- | --- |
| **Hover** | Pointer devices only | Skip it. Touch and gamepad have no hover — never hide information behind one. |
| **Focus ring** | Keyboard/assistive focus | You still need it: D-pad, gamepad, and remote navigation all need a visible cursor. Draw it yourself. |
| **Reduced motion** | Read the OS setting | Expose your own setting and default it off. Never ship with no way to turn motion down. |
| **System contrast mode** | Honour it | Ensure structure survives on borders alone, so a high-contrast pass is possible later. |
| **Assistive announcements** | Use the platform API | At minimum, never communicate a state change *only* through an animation. |
| **Back navigation** | Hardware/browser/swipe back | Provide an explicit affordance and keep the same restore semantics. |
| **Text scaling** | Honour the user's size | Lay out so a 1.3× text size does not clip or overlap. |

The rule behind the table: **these are behavioural requirements, not widget
requirements.** If your engine has no button primitive, you still owe the user
seven states.

---

## 1 · Every interactive element has seven states

Implement all seven. A control that only has "normal" and "pressed" is
unfinished — most of the perceived quality of these apps lives in the other five.

| State | Behaviour |
| --- | --- |
| **Rest** | Reads as interactive and quiet; the accent sits on the primary action. |
| **Hover** | Colour and border shift within 150ms, on pointer devices; position and size hold still. |
| **Focus** | A **visible ring**, 2 units thick with a 2-unit gap, in the look's interactive hue — on every interactive element, for keyboard and assistive focus. |
| **Press** | Acknowledged within 60ms, however long the work takes. |
| **Busy** | The label stays, the content dims, progress shows, assistive tech hears it, and the control fires once. |
| **Disabled** | Legible at ~45% opacity, with the reason in its own label — see §3. |
| **Error** | The cause and the next move, inline, beside the thing that failed. |

**Selected / active** is an eighth state for anything in a set (tabs, modes,
list rows). It must differ from hover by more than brightness — a border, a
fill, or a marker.

### Per-look expression

| | Console | Punch Clock | House Pour | Field Notes | Yama-Bruh |
| --- | --- | --- | --- | --- | --- |
| Hover | border warms toward accent | border + label take cyan | brightness +4% | lavender spine grows, title glows, dotted underline goes solid | teal wash on an entry; a grey cap steps its moulding lighter |
| Focus ring | accent blue | cyan | gold, plus a soft halo | amber | teal |
| Press | none (it's an instrument) | scale 0.96 + dim, springs back | drops 1 unit | none | drops 2 units, chamfer flattens |
| Disabled | 40% | 45% | 45% | rare — prose has few controls | 50%, reason on the status line |
| Selected | 2-unit accent edge | panel step-up + cyan border | filled pill on the active leaf | amber heading weight | lit in place — teal wash, or green cap with a teal glow |

**Field Notes takes `settle` (300ms) for hover rather than `quick`**, on
purpose — reveals in a reading context should be unhurried. It is the one
sanctioned deviation from §2; everything else in that look still uses the scale.
Field Notes also has no `--good`/`--bad`: it reports no status, so the state
ramp in §4 simply does not apply there.

**Yama-Bruh lights selection in place.** It is hardware: a chosen bank entry or
pattern cap lights where it sits, on `instant`. That is its sanctioned deviation
from §2a's travelling indicator.

---

## 2 · The motion scale

Five durations. Every animation in the kit is one of them — if you reach for a
sixth, you're guessing.

| Name | Duration | Curve | Used for |
| --- | --- | --- | --- |
| **instant** | 60ms | standard | press acknowledgement |
| **quick** | 150ms | standard | hover, focus, colour and border changes |
| **settle** | 300ms | standard | a value rolling, a row collapsing, a small reveal |
| **move** | 600ms | emphasised | something changing identity — skin morph, font crossfade |
| **arrive** | 850ms | decelerate | a meter or bar filling to its real value on landing |

Exit animations are the one place a look may pick its own curve — `sharp` for
something leaving decisively, `standard` for something dissolving. Durations
still come from this table.

Rules:

- **Never animate on first paint.** The app arrives already assembled. The one
  exception is a value that fills to its real number after a screen *change* —
  that's arrival, not entrance.
- **Exit is slower than enter.** Leaving reads at 0.4–0.7s; entering the same
  element replays it faster, ~0.3s. Removal needs to be seen; arrival doesn't.
- **Enter is exit reversed.** Same curve, opposite direction. One definition,
  two directions, guaranteed coherence.
- **Loops are for ambient state only** and must be slow — 5s for a background
  alert pulse, ~0.9s for a "this is live" breath. Anything faster is an alarm,
  and everything becomes an alarm if more than one thing is looping.
- **Transforms are per-look.** Console doesn't move. Punch Clock springs on
  press. House Pour drops one unit. Don't port one look's motion into another.

### Reduced motion

Honour the system setting. Reduced motion is **not "no feedback"** — the state
change must still land, it just stops travelling. Keep colour and opacity
changes, drop transforms, loops, and long sweeps. A user with the setting on
must still be able to tell that their tap worked.

---

## 2a · Transitions between states

Every state change is a transition. A control travels from one state to the
next on a named duration and curve, and its label, colour and indicator arrive
as one event. Focus position is the single instant change — the ring appears at
its destination at keyboard speed.

### The transition table

| From → to | Duration | Curve | What moves |
| --- | --- | --- | --- |
| rest ↔ hover | quick 150ms | standard | colour, border; the look's lift |
| hover → press | instant 60ms | standard | the look's press |
| press → release | settle 300ms | decelerate (spring looks: spring) | the press returns to rest |
| press → busy | quick 150ms, after the grace dwell | standard | progress fades in, content dims to 60% |
| busy → done | settle 300ms | emphasised | progress resolves into a confirmation mark, the label morphs to the done copy, the boundary warms to good |
| busy → error | settle 300ms | emphasised | the boundary turns bad, the label morphs to the cause |
| done → rest | settle 300ms | emphasised | the label morphs home, the boundary returns |
| enabled ↔ disabled | quick 150ms opacity, settle 300ms label | standard / emphasised | opacity between 100% and ~45%; the label morphs to or from the reason |
| label A → label B | settle 300ms | emphasised | outgoing lifts 3 units and fades on quick; incoming rises 3 units into place on quick; width tweens to fit across the settle; digits roll |
| sibling A → sibling B | settle 300ms | emphasised | one shared indicator travels to the new leaf; the old label dims as the new one brightens, crossing at the midpoint |
| on ↔ off | settle 300ms | emphasised | the marker travels; fill colour changes at the midpoint |
| focus A → focus B | immediate | — | the ring appears on the new element |

### Dwell times

Held states, measured from the moment the state lands:

| Dwell | Length | Purpose |
| --- | --- | --- |
| **grace** | 300ms | work that finishes inside it goes straight to done, and busy stays hidden |
| **busy minimum** | 600ms | once busy appears it holds this long, so progress reads as progress |
| **confirmation** | 1200ms | done holds, then settles home |
| **error** | until the user acts | the cause stays on the control; the next press returns it to rest |
| **toast** | 2200ms | see §3 |

### Rules

- **Interruptible.** A new state starts from wherever the current transition
  is, retargeting the in-flight value. Transitions replace each other.
- **One indicator travels.** Selection in a set is a single element moving
  between siblings. That continuity is what reads as "moved here".
- **Label and state start together.** The label morph and the colour change
  share a start time, so text and state arrive as one event.
- **The committing control locks** from press until it returns home, so an
  action fires once.
- **Confirmation returns home** — to its original label, or, for an action that
  changes the control's own state (punch in → punch out), to the label of the
  new state.
- **Reduced motion:** position, scale and rotation land immediately; colour,
  opacity and label crossfades keep their quick step; dwell times hold.

### Per-look expression

| | Console | Punch Clock | House Pour | Field Notes | Claudecore | Yama-Bruh |
| --- | --- | --- | --- | --- | --- | --- |
| Selection | 2-unit accent rule slides under the leaf | panel-2 block with a cyan edge glides between square leaves | ivory pill glides along the tray | amber underline moves to the current link | 2-unit sky bar glides on the spring | the chosen cap or entry lights in place |
| Label | crossfades in place | lifts out, rises in | lifts out, rises in | link colour shifts over 300ms | retreats on ease-in, arrives on the spring | silkscreen holds; the LED cuts to the new value |
| Busy | accent hairline sweeps the bottom edge | the live square breathes before the label | gold ring turns at the label's end | the space is reserved and still | ring turns over the dimmed label | the waiting control breathes on a 0.9s loop |
| Done | boundary and label settle to good | the fill flips to the new state's colour | ring resolves into a serif check, boundary warms to good | underline turns solid | glow warms to good | the LED shows the value, the status line names it |
| Error | boundary and label settle to bad | fill to red, cause in the label | cause in the label, soft bad ring | the missing thing, named in one serif sentence | glow and label to bad | the LED reads Er and blinks twice over 600ms |
| Press → release | colour only | scale 0.96, springs back | drops 1 unit, settles back | colour only | scale 0.96 on micro, springs back | drops 2 units, chamfer flattens, pops up on instant |

Each look's reference page plays these under **States in motion**.

---

## 3 · Feedback

### The disabled control says why

The strongest rule in the kit, lifted from DrinkTix's charge button. A disabled
primary action carries the reason **in its own label**, and updates live:

```
nothing selected   →  "Charge to tab"        (disabled)
selection valid    →  "Charge $25.00 to tab" (enabled)
insufficient funds →  "Need $4.50 more"      (disabled)
```

The user never has to hunt for why they're stuck. Apply this anywhere an action
is gated: the label is a state readout, not a fixed string.

### Transient confirmation — the toast

One channel, one at a time, **2200ms visible, 250ms fade**. Bottom centre.
Carries a neutral, good, or bad variant. Use it for: completed actions, failed
validation, background results.

Never use it for: anything the user must act on, anything that must persist,
or errors attached to a specific field — those go inline.

### Destructive actions confirm, and the confirm states the damage

Not "Are you sure?" — say what will be lost and whether it's recoverable:

> "Delete Ana's account and history? This can't be undone. $42.50 unspent."

Anything that destroys data needs this. Anything reversible does not — prefer
an undo affordance over a confirmation dialog wherever the action can be undone.

### Optimistic updates

Local-first apps (all of these) apply the change immediately and reconcile
after. If reconciliation fails, revert **and** toast the failure. Never show a
spinner for work that completes locally in under 100ms.

---

## 4 · State is encoded three ways

Colour, label, and geometry — always all three. Hue alone fails for
colour-blind users, in greyscale, in bright sun, and at a glance from across a
room.

The reference ramp (from Forget Me Not's urgency model, expressed as a ratio of
elapsed time to cadence):

| Band | Threshold | Colour | Label | Geometry |
| --- | --- | --- | --- | --- |
| OK | ratio ≤ 1.20 | green | `on time (94%)` | full-height bar |
| Late | ratio ≤ 3.00 | orange/amber | `late (210%)` | reduced height |
| Critical | ratio > 3.00 | red | `overdue (410%)` | full track + ambient pulse |

Named thresholds live in one place and are referenced everywhere. A magic
number inline in a component is how two screens end up disagreeing about what
"late" means.

**Always render the label.** A bare coloured bar is not a state.

---

## 5 · Screen states

Design all six before shipping a screen. Five of them are where products feel
unfinished.

1. **First run** — never an empty grey box. Say what this screen is for and
   give exactly one action.
2. **Empty** — factual, centred, quiet. `No tabs open yet.` `no takes yet.`
   Not "Nothing here yet — create your first task!" No illustration, no
   encouragement, no exclamation mark.
3. **Loading (first paint)** — show the *shape* of what's coming if you know
   it. Reserve the space so nothing jumps when data lands.
4. **Loading (user-triggered)** — that's **busy**, not a skeleton. The control
   the user pressed owns the progress indication.
5. **Partial / stale** — say which part is old and when it was last good.
   Silent staleness is worse than a visible error.
6. **Error** — cause plus next move, in the look's voice. `device busy — check
   input 2`. `Not enough on this tab.` Never "Oops! Something went wrong."

### Layout stability

Nothing may jump after load. Reserve space for images, meters, and async
values. A number that changes every second is **tabular** so its digits don't
shift the layout as they roll.

---

## 6 · Responsiveness

Three size classes, defined by what the content needs rather than by device
names:

| Class | Roughly | Behaviour |
| --- | --- | --- |
| **Compact** | one hand, one column | Single column. Primary action spans the full width. Paired buttons stack. Rails and side panels unpin and become inline or drawer. |
| **Medium** | tablet, split window | Two columns maximum. Persistent nav may show. Dense grids drop to two across. |
| **Wide** | desktop | Full layout. Content column still capped — see below. |

Rules that hold at every size:

- **Cap the reading column.** Text never runs the full width of a wide screen.
  Each look names its own cap (Console 1200, Punch Clock 760, House Pour 540 units).
- **Touch targets are 44 units minimum**, in both dimensions, regardless of how
  small the art is. Grow the target, not the icon.
- **Field text is 16 units minimum.** Below that, mobile browsers zoom the
  viewport on focus and strand the user; on other platforms it's simply the
  legibility floor for something being typed into.
- **Wide content scrolls inside itself.** Tables, code, diagrams, and timelines
  get their own scroll container. The page body never scrolls sideways.
- **Reflow, don't hide.** Dropping a column on a small screen is fine; dropping
  a *feature* is not. If something truly can't fit, it moves behind a
  disclosure — it doesn't vanish.
- **Respect the safe area** on notched devices, and keep the primary action
  inside thumb reach on compact.

Density is per-look, not per-screen: Console packs, Punch Clock is tight and
utilitarian, House Pour is generous with one decision per screen.

---

## 7 · Accessibility floor

Non-negotiable, all looks, all platforms.

- **Visible focus on every interactive element.** See §1.
- **Contrast:** 4.5:1 for body text, 3:1 for large text and for the boundary of
  any control the user must find. The dim/muted tokens are for secondary text —
  never for a label the user needs in order to act.
- **Never colour alone.** See §4.
- **Full keyboard path.** Every action reachable and operable without a
  pointer. Focus order follows visual order. Escape closes; Enter commits.
- **Focus is trapped in modals** and returns to the trigger on close.
- **Announce async changes** — busy, completion, and errors — to assistive tech,
  not just visually.
- **Honour reduced motion** and high-contrast/forced-colour modes: structure
  must survive when backgrounds are stripped, which means real borders rather
  than background-only separation.
- **Label every icon-only control.** An icon is not a name.

---

## 8 · Navigation and transitions

- **Wipe when the screen changes meaning** — mode, identity, language. Full
  choreography (see `skin-swap.md`): freeze the tweens, wipe, crossfade what
  can't tween, re-animate one signature element on arrival.
- **Tween when values change** — a filter, a sort, a re-theme. 600ms morph, no
  wipe.
- **Nothing for navigation within a section.** Moving between list and detail
  should feel instant, not cinematic.
- **Back must work**, including the hardware/browser back and swipe gestures,
  and it must restore scroll position.
- **Deep links carry state.** A mode, a theme, a selected item should be
  expressible in the URL or equivalent, so it can be shared and restored.

---

## 9 · Persistence

These are local-first products. The rules that come with that:

- **Never lose user input.** Drafts survive navigation and reload.
- **Say where the data lives**, especially when it's only on this device, and
  give an export path. If there's no backup, say so plainly at the point of
  risk.
- **Restore where they were** — scroll position, selected mode, open section.
- **Migrations are versioned.** A shape change bumps a version and backfills
  existing records with real defaults rather than relying on null checks.

---

## 10 · Ship checklist

Before reporting a screen done:

- [ ] All seven states implemented on every interactive element, focus included.
- [ ] Every duration is one of the five named steps.
- [ ] Every state change in §2a is a transition: labels morph, selection
      travels, done returns home, dwell times hold.
- [ ] Reduced motion honoured, and feedback still lands with it on.
- [ ] Disabled primary actions say why, live.
- [ ] All six screen states designed, not just the happy one.
- [ ] Nothing jumps after load; live numbers are tabular.
- [ ] Works at compact, medium, and wide; nothing hidden that isn't also
      reachable; body never scrolls sideways.
- [ ] Touch targets ≥44 units; field text ≥16 units.
- [ ] Keyboard-only pass completed end to end.
- [ ] No state communicated by colour alone.
- [ ] Destructive actions confirm and state the damage.
- [ ] Copy follows the look's voice — no emoji, no exclamation marks, no
      "Oops", no cheerleading.
