Harbor DS / foundations
Calm on the surface. Exact underneath.
Stone, paper, olive and clay for the calm. One violet for the moment that matters, and a clear next step on every screen.
01 / Color
Ramps
Authored in OKLCH, one hue per family. Ratios are computed live from the rendered swatch against white (w) and against stone-950 (k).
Semantic surfaces and text
Components reference these, never the ramps. Each tile shows the three text tiers on that surface.
02 / Clinical status
Six roles, never color alone
Every status carries an icon and a word. Violet is never a status, olive is never anything but a call, and normal results stay neutral.
See it in use
Critical value, severe allergy, destructive action. The scarcest color.
High or low value, overdue, needs attention.
Signed, approved, verified, final. Powder forest, always with a check or signature.
What the system knows: a result arrived, a fax was filed.
Preliminary, unsigned, awaiting. Neutral, dashed, clock.
Entered in error.
02b / Two greens
Light sage calls. Deep forest approves.
Lightness does the separating, so it survives colour blindness. Calls always carry a handset; approvals always carry a check or a signature.
See it in use
Distance between the pair (computed from the token source)
02c / Action rule (open question)
Sumi does the work. Violet moves you forward.
Proposal: everyday primary buttons are sumi ink. Violet is kept for brand moments and the one key action per screen, so it still pops against the natural base.
See it in use
The "Primary rule" switch in the rail applies the alternative to every primary button on this page.
03 / Contrast
Computed, not estimated
Every pair the foundations use, checked in light, dark and both contrast-more themes at build time. Text needs 4.5:1 with no large-text exemption; controls and the focus ring need 3:1.
All pairs
04 / Type
Two faces, one job each
Inter speaks and does the work: headlines at 24px and up, light subheads, thin accents, every label, sentence and number. Geist Mono joins in DS-02.
Data: tabular, slashed, unambiguous
05 / Space, radius, elevation
A 4px grid with room to breathe
Lift means you can act on it. Tint means it is telling you something.
Space
Radius
Semantic radii follow density: control, card, panel and tag step down one size in compact.
06 / Focus and density
Visible from across the room
One stroke, never two. A bordered control's own border turns deep violet at 2px. A filled button gets nothing outside it: its fill steps darker or lighter and a 1px stroke of its own hue appears just inside the edge.
Current density
Switch density in the rail. These values come from density.*.json and re-flow everything on the page, including the compositions below.
06b / Avatars
Photo first, earth-toned initials after
Patient photos are approved for the chart header. Without one, initials sit on an earthy tint picked from the person's ID, never olive or forest.
See it in use
07 / Layers, breakpoints, motion
The quiet tokens
Named layers instead of raw z-index. Breakpoints for the page shell only. Motion values are placeholders until the motion phase.
z-index
Breakpoints and containers
Motion (placeholder)
08 / Bento grid
Give it a size, it lays itself out
Tiles declare width and height in grid units. Rows are one unit tall, with one gap both ways, so two 2-unit tiles stack to exactly one 4-unit tile. Below 360px it is one column, in reading order.
See it in use
Each tile prints its live span (columns × row units) for the width it is at now. Resize the window: the spans and column counts change per container tier (narrow 360, medium 560, wide 800, xwide 1040), and the order you read and tab through never changes.
Coverage
columns { narrow: 2 } · copay 1×2 and prior auth 1×2 stack beside deductible 1×4- Copay today
- $25
- Deductible met
- $1,840
- of $2,500
- $660 to go this year
- Prior authorization
- None
Today at a glance
columns { narrow: 2, wide: 4, xwide: 6 } · link tiles; widths change per tier, the order never doesVitals
columns { narrow: 2, medium: 3, xwide: 6 } · blood pressure keeps 2 columns, the rest fill in- Blood pressure
- 128/82 mmHg
- Heart rate
- 72 bpm
- SpO2
- 98 %
- Temp
- 36.8 °C
- Weight
- 68.2 kg
Schema (generative-UI primitive)
The typed schema and its fixtures live with the component now: Components, BentoGrid. These three mosaics are rendered from it by the build; HarborBento.render stays the page's runtime for generators, with its own validation.
09 / Compositions
The foundations carry real screens
Each shell is built from tokens, plain HTML and the components listed below, and filled with real clinical content.
Patient header
clinical canvas, follows the density toggleLab values with reference range and flag
resize the window: the row reflows on its container, not the viewportBasic metabolic panel + CBC
Insurance card
paper canvas, density pinned to relaxed on this subtreeYour visit today
We checked your plan before you arrived. There is nothing you need to do.
- Copay
- $25
- Deductible met
- $1,840
- of $2,500
- $660 to go this year
- Prior auth
- None
So the clinic can bill your plan directly. Your card photo is stored with your chart and never shared.
Patient intake question
paper canvas, relaxed subtree, one decision per screenHow will you pay for today's visit?
We check your coverage before you arrive, so the front desk has nothing to chase.
10 / Layout and composition
Lines you feel, boxes you don't need
One grid per breakpoint, one spacing ladder, and a short list of things that never break. Space groups, a shared tint gathers, and a hairline encloses only what you can act on.
The grid at this width
live: this page's real margin, columns and gutterTier . Resize the window: the margin and column count change at the breakpoints (600, 900, 1200, 1600); the gutter follows density, so it matches the gap between cards.
Page grid, per breakpoint
to scale, fromgrid.columns, layout.margin, space.gutter, rail and pane widthsSpacing ladder
each rung separates what the rung below it groupsDo and don't
proximity, alignment, wrapping, truncation, enclosure, reading orderStacks, clusters, grids, bento
pick by what has to line upSticky and scrolling
regions numbered in reading order, the same order on every device11 / Control sizes
Six sizes, one formula
md is the control you already know. Each tier up adds one 4px grid step of height; everything inside grows at a fixed fraction of that step. The touch floor never changes the drawing: it is an invisible hit area.
See it in use
v(tier) = max(floor, v(md) + rate × 4px × (tier − md))
- height rate 1, no floor: every tier draws its formula height in both densities and on any pointer
- padding-x and icon rate 0.5; the drawn width hugs the label, with no minimum
- label size, gap and radius rate 0.25; label floor 12px
- padding-y derived, so the label is hugged and centered
- hit area max(drawn, size.target-min) on each axis, centered and invisible: 24 in compact, 44 in relaxed and on a coarse pointer
- space.touch-gap 2 × the xs hit extension, the closest two controls may sit; space.touch-inset half of it, the least padding a surface gives its controls
The dashed boxes below are a docs-only overlay of each control's real hit area; on a product surface it draws nothing. Focus stays on the drawn box. resolveControlSize('auto', context) picks lg under a 360 pane. Tokens: --harbor-control-<tier>-* and --harbor-space-touch-*, generated from src/size.ts.
Compact, fine pointer
The target is 24 (WCAG 2.5.8), which every tier already draws: the hit area is the drawn box.
xs drawn 24, hit 24sm drawn 28, hit 28md drawn 32, hit 32lg drawn 36, hit 36xl drawn 40, hit 402xl drawn 44, hit 44| Token | xs | sm | md | lg | xl | 2xl |
|---|---|---|---|---|---|---|
| drawn height | 24 | 28 | 32 | 36 | 40 | 44 |
| hit area, fine (per side) | 24 (+0) | 28 (+0) | 32 (+0) | 36 (+0) | 40 (+0) | 44 (+0) |
| hit area, coarse (per side) | 44 (+10) | 44 (+8) | 44 (+6) | 44 (+4) | 44 (+2) | 44 (+0) |
| padding-x | 8 | 10 | 12 | 14 | 16 | 18 |
| padding-y | 5 | 7 | 8 | 9 | 10 | 11 |
| label size | 12 | 12 | 13 | 14 | 15 | 16 |
| icon | 10 | 12 | 14 | 16 | 18 | 20 |
| gap | 6 | 7 | 8 | 9 | 10 | 11 |
| radius | 4 | 5 | 6 | 7 | 8 | 9 |
Compact, coarse pointer (preview)
The drawing stays compact; each hit area grows to 44 × 44, and the samples step apart by space.touch-gap.
xs drawn 24, hit 44sm drawn 28, hit 44md drawn 32, hit 44lg drawn 36, hit 44xl drawn 40, hit 442xl drawn 44, hit 44Relaxed
xs and sm are drawn at 36 and 40, smaller than md; their hit areas are 44 tall. A coarse pointer changes nothing more here.
xs drawn 36, hit 44sm drawn 40, hit 44md drawn 44, hit 44lg drawn 48, hit 48xl drawn 52, hit 522xl drawn 56, hit 56| Token | xs | sm | md | lg | xl | 2xl |
|---|---|---|---|---|---|---|
| drawn height | 36 | 40 | 44 | 48 | 52 | 56 |
| hit area, fine (per side) | 44 (+4) | 44 (+2) | 44 (+0) | 48 (+0) | 52 (+0) | 56 (+0) |
| hit area, coarse (per side) | 44 (+4) | 44 (+2) | 44 (+0) | 48 (+0) | 52 (+0) | 56 (+0) |
| padding-x | 16 | 18 | 20 | 22 | 24 | 26 |
| padding-y | 11 | 12 | 13 | 14 | 15 | 16 |
| label size | 12 | 13 | 14 | 15 | 16 | 17 |
| icon | 12 | 14 | 16 | 18 | 20 | 22 |
| gap | 6 | 7 | 8 | 9 | 10 | 11 |
| radius | 8 | 9 | 10 | 11 | 12 | 13 |
12 / Content rules
Two dates, one clock
A date reads as a sentence where a patient reads it, and as a column where a clinician scans it. Machines always get ISO.
| Role | Renders | Formatter (src/format.ts) | Where |
|---|---|---|---|
| Display date | formatDate(iso, 'display') | Patient-facing surfaces, cards, calendars, headings | |
| Body date | formatDate(iso, 'body') | Practice-facing tables, patient data, charts, dense lists | |
| Time | formatTime(iso) | 12-hour with AM/PM by default; 24-hour is an org setting | |
| Date and time, display | formatDateTime(iso, 'display') | A patient-facing moment | |
| Date and time, body | formatDateTime(iso, 'body') | A practice-facing timestamp |
<time datetime>always holds the ISO value; the words are for people.- Shown as written, in the clinic's wall clock: the formatter never converts zones, so every machine prints the same thing.
- Never breaks inside: a date or time moves whole to the next line (DS-P1 §15.7).
- In a view model a date is a run,
{ date, role },{ time }or{ dateTime, role }, never a preformatted string. The clock defaults to 12h.
Where the roles are applied in this specimen
Collected by the build from every date in the site sources: patient-facing surfaces use the display date, practice-facing ones the body date.
| Source | Role | Renders |
|---|---|---|
site/demo/approval.html | body date and time | 10/23/2026, 10:30 AM |
site/demo/labs.html | body date and time | 10/23/2026, 7:40 AM |
site/demo/patient-hero.html | display date | Oct 23, 2026 |
site/foundations/13-compositions.html | body date and time | 10/22/2026, 7:40 AM |
site/foundations/foundations.js | body date and time | 10/22/2026, 7:40 AM |
site/layout/layout.js | body date and time | 10/23/2026, 10:30 AM |
site/layout/layout.js | display date | Oct 23, 2026 |
site/shells/insurance-card.html | display date | Jan 1, 2026 |
site/shells/patient-header.html | body date | 03/09/1985 |