The app is a native macOS app, so its palette is macOS's. Every colour is a system semantic
colour or a fixed alpha on one, and the single accent is NSColor.systemBlue — a named system
colour, picked here rather than read off the machine. No hue is hand-mixed, so the app follows the
system's appearance with no override, no light-only assumption, and no second set of values for Dark.
Display type is Open Runde, bundled and ours; reading type is SF Pro.
This replaces an Anthropic palette (ivory #FAF9F5 paper, #141413 ink, clay #D97757) over New
York, which read as a piece of someone else's brand pasted into the menu bar.
Implemented in Sources/ContextApp/Onboarding/Ink.swift. Every colour and every type role in the app
comes from there — a colour literal anywhere else is a bug.
Twelve roles. The right-hand column is the whole definition; there are no hex values to keep in sync.
surface NSColor.controlBackgroundColor the onboarding sheet, the popover ground
primary NSColor.labelColor headlines, row copy, the primary button fill
secondary labelColor @ 0.80 a sentence someone reads
tertiary labelColor @ 0.68 a glance word — opaque surfaces only, never on glass
separator NSColor.separatorColor a rule between blocks
hairline labelColor @ 0.22 the edge of something you press
accent NSColor.systemBlue the one actionable link
errorRed NSColor.systemRed the only place the app raises its voice
listeningGreen NSColor.systemGreen the 7 pt live dot
rowFill labelColor @ 0.045 onboarding permission-row fill
rowFillHover labelColor @ 0.085 the same row under the pointer
rowHover tertiaryLabelColor @ 0.12 a *menu* row under the pointer
wash labelColor @ 0.06 the pressed state of anything with no fill
glow white the finale's `plusLighter` overexposure
AppKit twins for the layers SwiftUI cannot reach: nsSurface, nsPrimary. There is deliberately no
nsAccent — a second definition of the accent is a second thing to keep true, and AppKit callers
that need it can say NSColor(Ink.accent).
Three steps of type colour, and no fourth. primary → secondary → tertiary, chosen by what
the reader has to do with the text, not by how deep it sits in a stack:
| use | colour |
|---|---|
| headlines, row copy, anything with an action attached | primary |
| a sentence someone reads: prose, a status line, an upload note | secondary |
a word someone glances at: Granted, Sign out |
tertiary |
A state with something to do about it ("Not signed in…", "Not connected to Claude") gets full
primary, not less. tertiary is for a single word beside its subject and never for a sentence.
On glass,
tertiaryis not available. Promote it tosecondary.
This is the single most consequential rule in this document, and it is arithmetic rather than taste. A translucent panel's ground can only be as dark as the faintest rung anyone sets on it, and the ground is what "glassmorphic" means. So whichever rung sits at the bottom decides how see-through every panel in the app is:
| ladder on glass | ground over black | passthrough | bottom rung there |
|---|---|---|---|
| three rungs | 205.5/255 | 17.0% | tertiary 4.56:1 |
| two rungs | 154.1/255 | 34.8% | secondary 4.58:1 |
Twice the desktop, at the same 4.5:1. Everything else — the material, the corner, the shadow — is identical between those two rows; the ladder is the whole difference. Three separate retunes of the material and the scrim were shipped before this was understood, each moving the panel by a few levels out of 255 and each correctly reported as no change at all.
Every surface in the app is glass except the menu bar popover, which is AppKit's own NSPopover
chrome (see The glass). So the popover keeps all three rungs and everything else gets two.
What this costs: a run that was tertiary because it is a sentence was on the wrong rung anyway
and is simply corrected. A run that was genuinely a glance word — the status on a permission row, a
card's app · time caption, a section header — loses a step of hierarchy and has to earn its
recession from size, weight, tracking or position instead. That is the price of the panel, paid
deliberately.
And it really is drop-or-nothing. To clear AA on the shipped ground tertiary would have to darken
to 0.796, which is secondary's 0.80 to within a hundredth — a third rung that measures as the
second is the same colour twice with an extra token to keep true. Asserted, not asserted-looking:
InkGlassTests.testTheBottomRungIsWhatPaysForTheGlass measures the rescue alpha and fails if it ever
comes apart from secondary.
Held four ways:
InkGlassTests.testTheLadderClearsWCAGAAOnTheGlass…—primaryandsecondaryclear AA on the real ground, in both system appearances, over both extreme desktops.InkGlassTests.testTheBottomRungIsWhatPaysForTheGlass—secondaryclears AA by less than 0.30 (the ground is on its floor, not above it) andtertiaryfails there (so the ground cannot drift back up to where a third rung would fit).InkGlassTests.testTheTimelinesHourLabelsClearWCAGAAOnTheGlass— the timeline's hour marks, which are drawn in AppKit and hand anNSColorstraight to anNSAttributedString, measured on the colourRewindTrackView.hourLabelAttributesactually carries.InkGlassTests.testNoGlassSurfaceSetsTypeOnTheBottomRung— a labelled static tripwire overSources/ContextApp. Its default is "this file is glass", and every exception is declared per file and per spelling, as an exact count rather than a ceiling.
The migration debt is paid. Six glass files once carried nineteen Ink.tertiary call sites at
~3.6:1; all six are at zero and the debt list is gone rather than kept at its old numbers — a stale
allowance is an allowance to put the call sites back, which is precisely what a ceiling-shaped ratchet
had been licensing. The declarations that remain are the popover (not glass) and two fills.
The rule is "no faint type on glass", not "no Ink.tertiary on glass". The token is one spelling
of it. The sweep also looks for the system's own faint label steps and SwiftUI's hierarchical styles,
because the way this was actually broken never touched the token: the timeline set its hour labels in
NSColor.tertiaryLabelColor — 1.70:1 on the shipped ground, about half as legible as the rung the
rule bans. On that ground secondaryLabelColor is 2.98:1, tertiaryLabelColor 1.70:1 and
quaternaryLabelColor 1.21:1, all over a solid black desktop.
Why the two lower steps are alpha on labelColor and not secondaryLabelColor /
tertiaryLabelColor: the system's steps are 50% and 26% black in Light, tuned for dense system
chrome — an inspector row, a table cell, a menu — where the reader glances at a word beside its
subject. Over surface they measure 3.95:1 and 1.88:1, so the step that carries onboarding's prose
was under WCAG AA for body text (4.5:1) and the step below it was barely a shade. Onboarding is the
one screen that asks for sustained reading, and it was reported as hard to read.
Measured over surface, composited in linear light (MenuBarPresentationTests asserts these):
| step | Light | Dark |
|---|---|---|
primary (labelColor, 0.85) |
14.9:1 | 12.2:1 |
secondary (labelColor @ 0.80) |
7.8:1 | 8.3:1 |
tertiary (labelColor @ 0.68) |
5.2:1 | 6.3:1 |
That table is the opaque surface — the popover, and any sheet drawn on surface alone. On glass
the ground is not surface and the readings are different; see The glass.
secondary is held to AAA (7:1) over surface because it is the only step that carries whole
sentences — and on glass it is the bottom rung, where its alpha is set by the worst ground rather
than the best: over a solid black desktop it measures 4.58:1, eight hundredths above AA. That is what
pins it from both sides. It cannot be thinned (the glass falls under AA) and it must not be thickened
(contrast the rung does not need is opacity the panel paid for, which is what an opaque-looking panel
is made of).
tertiary is the glance step on the opaque surface only. It cannot go darker either — at 0.69 it is
7.8 L* from secondary on a Dark sheet, under the 8 L* the rungs are held apart by. On surface
the ladder is still three visible steps: L* 15.6 / 35.0 / 45.7 in Light, 88.0 / 74.2 / 65.7 in Dark.
Why the fills are alpha on labelColor rather than named fill colours, and the same reason the
type steps are: they have to composite correctly over both the onboarding sheet and the popover's
vibrant material. A wash that darkens in Light and lightens in Dark does; a fixed grey does not.
rowHover is the one exception that stays on tertiaryLabelColor — it is a fill nobody reads, and
it is supposed to be barely there.
Why the primary button is not accent-filled: a filled accent button owes a label colour legible
on it in both appearances, which is one more contrast pair to keep true. Inverting the label ladder
(primary fill, surface label) is high-contrast in both appearances by construction, and it leaves
the accent spent on the one link that needs it.
No purple anywhere — INV-UI-1. The accent used to be NSColor.controlAccentColor, the accent
the user picked in System Settings, on the reasoning that a borrowed brand is worse than a borrowed
hue. That was wrong in one specific way that no call site could correct: macOS offers Purple
there, so on that machine every ring, checkbox, toggle and link spending the accent rendered
purple. A rendered-pixel check cannot catch it either — every machine this was developed on has a
blue accent, so the guard came back green and the defect shipped anyway. The accent is therefore a
value this system picks. InkAccentTests holds the line three ways: the token's rendered pixel
through the shared BrandColour predicate, the token's catalog identity (which is what survives
being run on a blue Mac), and a labelled static tripwire over Sources/ContextApp for a view that
skips Ink and reaches for controlAccentColor inline.
There is no exemption left in that sweep, and there must not be a new one. One used to be listed
here and in the test: AccentChoice.system, the default in the Appearance pane, resolved to
controlAccentColor and tinted the whole Settings window with it, pinned by a
SettingsTests.testSystemAccentIsTheUsersOwn. AccentChoice and that test have both since been
deleted, and what survived them was a whole-file exemption with nothing to exempt — the worst state
for one to be in, because it reads as settled and stays green while licensing the next
Color(nsColor: .controlAccentColor) added to that file. Skipping a file is a hole; if an exception
is ever genuinely needed, the shape to copy is InkGlassTests's per-spelling exact counts.
There is no NSApp.appearance override, and there must never be one again. Pinning the process
to .aqua rendered a light popover inside a dark system menu — the loudest single reason the colours
read as wrong — and it silently overrode every AppKit surface the app does not draw itself: focus
rings, scrollers, the popover window's own background and corner rounding.
Every translucent surface in this app is one component: InkGlassView — the onboarding card, the
timeline, the settings window, the menu bar popover, the search panels and the tutorial's coach
marks. One NSVisualEffectView, one scrim, one corner, one shadow, built in InkGlass.swift and
hosted everywhere else. A translucent surface has four numbers that have to agree and six windows
copy-pasting them is six chances to disagree.
It is pinned to a light appearance (NSAppearance(named: .aqua), set on the panel view — or on
the whole window where there is a title bar to convert with it). NSVisualEffectView renders a
different, dark material in .darkAqua, and controlBackgroundColor resolves near-black there, so
on a Dark machine this surface read as a black slab pasted over the desktop rather than as glass. The
pin is also what makes labelColor — and therefore the whole Ink ladder — resolve dark on the
panel; a surface forced light whose type did not follow is white-on-white, which is nothing on screen
at all. InkGlassTests.testTheTypeResolvesDarkOnTheLightGlass is that assertion.
The material is .hudWindow, and the number that decides that is the material's own opacity — not
the scrim. This corrects an argument that stood here for two releases and is the reason two separate
attempts to make these panels see-through changed nothing anybody could perceive.
Every candidate was re-sampled on this machine (macOS 26, .behindWindow, .active, pinned .aqua)
over solid black, mid-grey and white full-screen backdrops, at scrim zero — each material at its
most transparent, which is the honest way to rank them:
| material | ground over black | passthrough | material | ground over black | passthrough |
|---|---|---|---|---|---|
hudWindow |
136.2/255 | 41.2% | menu |
188.4/255 | 18.8% |
fullScreenUI |
136.2/255 | 41.2% | titlebar |
208.0/255 | 15.4% |
popover |
163.7/255 | 29.4% | headerView |
212.9/255 | 16.2% |
selection |
193.4/255 | 8.5% | sidebar / underWindowBackground |
213.9/255 | 7.9% |
sheet, windowBackground and contentBackground are opaque in .aqua.
The old ranking asked the wrong question. It held the ground fixed at 209.1/255 and asked which
material passed the most desktop there, concluding that a pure white tint was optimal and
.headerView won. But 209.1 is not the legibility floor — it is well above it. The floor is the
darkest ground on which the faintest rung the panel carries still clears WCAG AA over a solid black
desktop. .headerView is ~84% opaque white on its own, so its ground bottoms out at 212.9/255: it
cannot reach any plausible floor at any scrim, and the scrim is worth four points of ground in total.
That is exactly what was observed — dropping the scrim 0.36 → 0.10 moved the onboarding card from
228.0/255 to 217.0/255, eleven levels out of 255, and was reported as no change at all.
.hudWindow is 59% opaque at a near-white tint (232/255), so it can be scrimmed a long way down.
Measured on the real material over a real banded desktop (.aqua, .behindWindow, .active,
sampled out of a screencapture):
| material / scrim | ground over black | passthrough | bottom rung there |
|---|---|---|---|
headerView @ 0.10 (shipped, three-rung) |
217.0/255 | 14.5% | tertiary 4.74:1 |
hudWindow @ 0.56 (shipped, three-rung) |
205.5/255 | 17.0% | tertiary 4.56:1 |
hudWindow @ 0.115 |
151.2/255 | 35.9% | secondary 4.48:1 — under AA |
hudWindow @ 0.14 |
154.1/255 | 34.8% | secondary 4.58:1 |
hudWindow @ 0.00 — the material's ceiling |
136.2/255 | 41.2% | secondary 3.96:1 ✗ |
The pin, not the machine, decides: re-run under a Light system and under a Dark one, the sampled composites are identical.
The scrim is surface @ 0.14, flat and full-bleed, painted over the material and under the
content. Its alpha is not comparable across materials and must never be read as a translucency
figure — it is a fraction of whatever the material left. Reading this number as "how see-through the
panel is" is what let three retunes ship without changing the picture. The quantities to reason about
are the ground and the passthrough. The ladder on the shipped ground, measured through the
real material over the two extreme desktops, in both system appearances:
| step | black desktop | white desktop |
|---|---|---|
primary |
6.34:1 | 13.78:1 |
secondary (the bottom rung on glass) |
4.58:1 | 7.46:1 |
tertiary |
3.61:1 ✗ | 5.09:1 |
The floor is the bottom rung, and the ground sits on it rather than above it. Three points of
ground thinner and secondary is under AA. InkGlassTests holds this from three sides — it fails if
the ground goes under the floor, if the rung ever clears AA by more than 0.30, and if tertiary
ever clears AA there (which would mean the ground had drifted back up to a three-rung panel).
A light panel over a dark desktop cannot show all of it, and no amount of tuning changes that. Dark type needs a light ground; the ground is pinned at the bottom rung's floor; so the whole range the panel occupies across every desktop that can exist is 154.1…242.8 of 255 — an 89-level span, against the 43 levels a three-rung panel had.
The remaining moves are exhausted, and each was checked rather than assumed:
- The material.
.hudWindowand.fullScreenUIare the thinnest macOS ships in.aqua; at scrim zero they bottom out at 136.2/255, and the shipped ground is 18 levels above that. There is at most 6% more passthrough available in the whole material catalogue and it is below the contrast floor. - A third rung. Gone, and it is what bought the current panel. Going further means spending
secondarytoo, which leaves one type colour and no ladder at all. - Dark glass — what "glassmorphic" usually means over a dark desktop — is worse under the same rule, because the binding case inverts: light type on a dark ground is worst over a white desktop, and a two-rung dark ladder caps out well under the 34.8% a light one reaches.
Everything left that reads as glass costs no contrast, so it is all spent: the broad ambient shadow, and a specular top edge — white @ 0.50, one point tall, top edge only, clipped by the panel's corner. A bright line where a light source above would catch the panel is the difference between a rectangle of pale grey and an object with a face on it, and because it brightens the ground it can only help dark type. Both are worth more now than they were, because a ground at 154 has real desktop moving under it for them to sit on. Both are hidden under Reduce Transparency along with the material: there is no glass there for light to catch.
A scrim only under the copy would be the speech bubble's grey slab drawn a second time, so it is
full-bleed. InkGlassTests asserts the ladder on a two-layer model of this ground — checked against
the real composite to about 3/255 — and InkGlass.measuredMaterialOpacity / measuredMaterialTint
record the samples the model needs, because a material's opacity is not published and cannot be
derived. Re-take those samples rather than adjusting them: the pair that stood here before were
.headerView at 0.800/pure-white from macOS 15.5, and re-measured it is 0.835 — which is why the
documented passthrough (18.0%) never matched the panel anyone was looking at (14.5%).
GlassRenderHarness (CONTEXT_GLASS_RENDER=1) renders the card over a dark synthetic desktop and
samples its own output, which is the check that could not have passed while the panel was a slab.
The corner is 22 pt, continuous, and it is one value for every panel: a 56 pt bar and a 760 pt
timeline rounded differently read as two products. The edge is labelColor @ 0.06 — much fainter
than hairline (0.22), which is the outline of a control; a panel that needs a drawn border is a
panel whose brightness and shadow are not doing their job.
The shadow is broad and faint: 34 pt radius, 0.24 opacity, 10 pt down. Almost all of the floating
quality comes from this, and the wrong shadow is not a missing one but a tight dark one. It is cast
from a shadowPath and masked so it never falls under the panel — behind an 18%-transparent surface
a filled blurred rounded rect is very much visible, and more so now than at the old scrim: the thinner
the ground, the more of the panel's identity the shadow and the edge are carrying. A floating panel's window is therefore larger than
the panel by InkGlassStyle.floating.inset (56 pt), because a borderless window clips at its own
bounds and a panel drawn edge to edge has nowhere to cast into.
Three styles, and they are not variations on a theme. .floating is a free-floating object over
the desktop (rounded, edged, ambient shadow, inset). .fullBleed is the inside of an ordinary titled
window — the timeline, settings — square and shadowless because the window frame already owns both.
.panel(cornerRadius:) fills its host exactly, for a surface something else already positions and
shadows.
Reduce Transparency is honoured, and it is the one branch in the file: the ground goes fully
opaque and the material is removed, not covered. InkGlassView.apply(reduceTransparency:) takes the
setting rather than reading it, because the domain that holds it cannot be written even by defaults,
so a view that read it directly would have an accessibility path nothing could assert. The shadow
stays — the panel is still a floating object and still has to read as one.
Open Runde above the display threshold, SF Pro below it. One threshold —
Font.inkDisplayThreshold = 22 pt — decides which, because the split is a fact about the role and
every role already encodes its role in its size. A headline is never 15 pt here and body copy is
never 24. Nothing takes a flag at a call site.
Open Runde is a geometric sans with rounded terminals: distinctive, and ours rather than borrowed. It replaces New York, which was reached for as a stand-in for Anthropic's editorial serif and therefore carried a brand this app has no business wearing. Reading type stays SF Pro, which is what a native macOS app should be setting body copy in anyway, and which is also where a bundled face buys least.
Bundled as Resources/Fonts/OpenRunde-{Regular,Medium,Semibold,Bold}.otf, copied into the bundle by
scripts/build.sh, and registered at launch by ContextAppDelegate.registerBundledFonts() with
CTFontManagerRegisterFontsForURL(.process). InkFonts.invalidate() runs straight after, so nothing
can keep a system-font stand-in it cached before registration.
They are OpenType/CFF, not TrueType: both the bundle copier and the launch-time registrar accept
.otf and .ttf. An extension filter that knows only about .ttf silently drops every face.
Faces resolve by PostScript name — OpenRunde-Regular, OpenRunde-Medium, OpenRunde-Semibold
(lowercase b), OpenRunde-Bold — because the family + weight route does not dependably reach them.
Two consequences:
- A name that does not resolve fails silently, degrading the whole display ladder to SF Pro with
nothing on screen to say so.
MenuBarPresentationTestsasserts every name resolves. Text.bold()on a Semibold headline has no bolder member to resolve to and the emphasis quietly vanishes.RandomizedTextnamesOpenRunde-Boldoutright instead.
A face that will not register costs a typeface, not a launch: InkFonts.resolve returns the SF Pro
font and SF Pro's metrics together, so the drawn face and the measured face can never disagree.
| role | weight | size | tracking | em | line height |
|---|---|---|---|---|---|
| intro hero | semibold | 34 | −1.19 | −0.035 | 1.10 |
| step headline | semibold | 27 | −0.81 | −0.03 | 1.18 |
| "First…" | semibold | 27 | −0.81 | −0.03 | — |
| body prose | regular | 17 | −0.17 | −0.01 | 1.55 |
| permission row copy | medium | 15 | −0.15 | −0.01 | 1.40 |
| status label | regular | 12 | — | — | — |
| button label | semibold | 15 | −0.15 | −0.01 | — |
Every size is one step up from the ladder that shipped (32/25/15/13/11/14), because onboarding was
reported as being set too small to read comfortably. Nothing else about a role changed — same face,
same colour, same leading multiple — so the card reads as the same design at a size that does not ask
the reader to lean in. Two thresholds bound the change and are asserted: every reading role stays
under the 22 pt display threshold, so it is still SF Pro; and prose stays under 18 pt, so WCAG still
treats it as normal text and the label ladder's bar stays 4.5:1 rather than the 3:1 large-text
allowance. The bar must not get easier because the type got bigger.
The tracking ladder is converted from em at each size, and this is the rule a size change
breaks silently: tracking is stored in points, so a role that grows and keeps its old point value has
quietly loosened — bigger letterforms with the same gap between them. Recompute from the em, which
is the fixed part. Tight at display, barely there at body, and nothing at 12 pt or under — there,
tightening closes the counters faster than it locks the words up. The hero's −0.035 em is the
signature: at display size a round, wide sans falls apart unless the words lock up.
InkType.minimumSize (12 pt) is the floor, and the status label sits exactly on it — which is why
that role is also the one with no tracking. The hero is the largest thing on any screen and stays that
way. Row copy is medium rather than regular because a row's sentence sits beside a heavier headline
and regular goes weedy next to it.
None of these roles appear in the menu bar popover. That surface is a menu and is set in plain SF Pro at system sizes — see below.
Line-height multiples tighter than the face's own leading (1.10, 1.18, 1.40) cannot be expressed
through .lineSpacing, which is defined as non-negative — those roles render at the face's natural
leading. InkTextStyle.leadingDelta is the escape hatch for a caller laying out lines itself.
Two column widths, and the difference between them is deliberate.
- Reading column 488 pt (
contentMaxWidth) — a headline and the sentences under it, centred. At 17 pt prose that is a little under 80 characters a line, the top of the range a paragraph stays readable across. Wider is not more generous; it is a longer distance for the eye to travel back along. - List column 560 pt (
permissionsMaxWidth) — the permissions step and the other left-aligned cards. This is what pays for the larger type: a permission row is not a paragraph but a sentence with a checkbox before it and a status word after it, and those fixtures take ~116 pt out of the row before the sentence starts. At 488 pt and 15 pt row copy every row wrapped to two lines and the setup preamble ran to five, putting the card 23 pt past the window. 560 pt is the card's own width (reading column plus both gutters), and it is also as wide as the column should go: 560 plus its padding leaves 80 pt of glass either side, and content that runs closer than that to a rounded edge reads as content that did not fit. - Page padding 36 pt horizontal, 34 pt vertical. The vertical figure is not a place to find height for bigger type: it is the only thing keeping a 34 pt headline off a rounded corner, and height is the dimension the cards actually run out of — the window is fixed and does not scroll.
- Everything centred, except the permissions, value, connector and tutorial steps, which are left-aligned and take the list column.
- Vertical rhythm: 28, 22, 18, 14, 12, 10, 8, 6.
- The onboarding window is 720 × 640 pt, and all of it is legible area — there is no falloff now.
- The foot of the card is a reserved band,
InkLayout.progressBandHeight(44 pt), that the progress dots live in. It is subtracted from the height a card's content is laid out in, so the dots can never land on the content — which they did, inside the fourth permission row and on top of the "Show me the row" button, while they were an overlay pinned 62 pt off the bottom edge. - The content budget is
OnboardingWindow.cardContentHeight— the pane, less the page's vertical margins, less that band. 492 pt today.
Resolved — the by-hand permission card's overflow. When Accessibility is not granted, the permissions step draws its headline, preamble and four rows and the by-hand choreography panel underneath. That needs 505 pt, and the card had 452, so SwiftUI compressed the rows and three of the four sentences truncated mid-word. Neither column width nor padding could fix it — the four rows cannot come back to one line at any width the sheet will hold, so the panel is always the overflow — so the card took the second of the two options this note used to offer and grew to fit its tallest state. Two things keep it fixed:
InkPermissionRow's sentence is.fixedSizevertically, so a row wraps and can never truncate. Too little room is now a measurement that fails rather than copy that quietly disappears.PermissionsCardLayoutTestsmeasures the realPermissionsCardthroughNSHostingViewin every state the flow reaches and asserts each fitscardContentHeight. Longer copy, a fifth row or a new panel fails that test rather than shipping.
The onboarding sheet's only action shape, and the popover has none of them.
Primary: primary fill, surface label, stadium (fully rounded), min height 42 pt, 24 pt
horizontal padding, the button-label role (15 semibold, and SF Pro like every role under the display
threshold). Secondary: same metrics, clear fill, primary label, 1 pt hairline border. No rounded
rectangles for actions — always a full pill.
Pressed lets the surface through: primary drops to primary @ 0.82, secondary fills with wash.
Colour only, no scale: a pill this size bouncing reads as a toy.
The same row renders on two surfaces that must not look alike, selected by InkPermissionRow(native:).
Onboarding (native: false) — a card. rowFill, 1 pt separator hairline, corner radius 13,
14 pt horizontal / 11 pt vertical padding. A fill strong enough to read on its own would box three
sentences into three grey slabs. Hover goes to rowFillHover.
Leading: an 18 × 18 checkbox, corner radius 6, 1 pt hairline, filling primary with a surface
checkmark when granted (180 ms). Then an 11 pt gap, the first-person sentence in primary at the
row-copy role, then the status word in secondary at the status-label role: Granted / Open /
Checking / Action required. secondary and not the glance rung because this card is on glass —
see The ladder is three rungs on an opaque surface and two on glass; the status still reads as
subordinate to the sentence beside it, which is primary. The checkbox and the two gaps take ~116 pt
out of the row before the sentence starts, which is why the list column is wider than the reading one.
Menu bar (native: true) — a menu item. See the next section. No fill at rest, no border, no
tracking, no drawn checkbox.
The whole row is tappable on both surfaces and requests the capability.
This surface is a menu, so it is drawn like one. It sits beside every other menu bar extra on the
machine; a filled row capsule and letter-spaced type are the single clearest tell that a panel was
drawn by a website rather than by macOS. None of the InkType roles appear here.
It is also the one surface in the app that is not InkGlassView — an NSPopover brings its own
frosted chrome from a window this process does not own — which is why it is the one surface that
keeps all three rungs of the ladder. Every tertiary below is on that basis.
- 320 pt wide, 12 pt horizontal / 8 pt vertical padding, 6 pt between groups.
Divider()between groups — never a hand-drawnRectangle.- Row height 22 pt, the height AppKit gives a menu item (
InkPermissionRow.menuRowHeight). Capability rows abut at spacing 0. - Row text: plain SF Pro at
NSFont.systemFontSize. Secondary lines (the transcript line, an upload note, a paused reason, a connector note) atNSFont.smallSystemFontSize. - One left edge, 18 pt in: a 12 pt checkmark column plus the 6 pt gap after it. Lines that sit outside a row are inset by hand to land on it. A menu whose text has two left margins is the other half of looking counterfeit.
- No fill at rest. Under the pointer a row takes
rowHoveron a 4 pt corner radius. - Capability rows: a leading checkmark when granted (the column stays open when not, so granted and
ungranted titles align), the noun in
primary, the status trailing intertiary. - The live dot is 7 pt,
listeningGreenwhen capturing andtertiarywhen paused, in that same 12 pt column. Pause/ResumeandQuitare menu commands, not buttons: full-width 22 pt rows, titles inprimary,⌘Qtrailing insecondary. A command set intertiaryinside a 22 pt row reads as disabled, which is the opposite of what either of these is.accenton repair links only. A repair link is the press that fixes a line the popover has just said is broken: "Connect" on the connector line, "Sign in" on the account line. There is at most one per line and they are the only presses on this surface that are not command rows. "Sign out" stays a quiet trailing link intertiary— it is a link on a line that is already settled, and a settled line asking for attention is the popover crying wolf.- The account line always has a way back in. It carried "Sign out" and nothing to undo it, which
made signing out a one-way door: onboarding does not run twice, there is no Dock icon, no window
menu and no account pane, so a signed-out user had no route to an account anywhere in the product.
Signed out, the trailing link is "Sign in"; pressing it discloses two provider commands
("Continue with Google", "Continue with Apple") on the shared left edge and turns the link into
"Not now", so the disclosure is not a one-way door either. While the browser round trip is open
the line reads "Waiting for your browser…" and offers nothing — a second press cannot start a
second sign-in.
AccountPresentationowns every one of those branches as a value andAccountPresentationTestsholds them; the view has no judgement of its own.
Colour is Ink throughout, which is system semantics, so the panel follows the menu bar's own
appearance. There is no appearance override on the popover or on the process.
The loopback OAuth page in LoopbackCallbackServer is the app's one web surface, and it is the one
deliberate exception to everything above: a browser page cannot read macOS semantic colours, so it
carries fixed values and pins color-scheme: light. It opens in the same browser as the product site
and is held to that site's palette: #FBF8F4 ground, #171412 heading, #6B625B body, #C9352B
when sign-in failed, Open Runde first in the font stack.
| element | spec |
|---|---|
| step transition | 240 ms easeOut, fade + slide from y +0.015 |
| word reveal | 1200 ms total; each word delay = 0.05 + random·0.18; opacity = easeOutExpo of ((t − delay) / 0.62) clamped |
| settle | 280 ms easeOut |
| checkbox | 180 ms |
| press | 90 ms |
| finale glow burst | 550 ms ease-out over a full-screen edge glow |
The finale burns the sheet out from its edges: a radial glow gradient in plusLighter, which
drives the outer ring to white. plusLighter only ever adds, so the exit has to be the brightest
value available or there is nothing to burn out to — over a dark sheet, adding a dark grey is
invisible. On a light sheet a fade to transparent would just be the surface becoming the surface.
Honour Reduce Motion everywhere. NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
→ all durations zero and the word reveal jumps to 1. Everything routes through InkReduceMotion, so
honouring it is a call rather than a discipline nobody keeps. Reduce Transparency is separate and
routes through InkReduceTransparency; see The glass.
Borderless, shadowless, floating, fixed 720 × 640 centred on the screen the pointer is on:
styleMask = [.borderless]
isOpaque = false; backgroundColor = .clear; hasShadow = false
level = .floating
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
contentView = <clear, first-mouse-accepting> // never layer-backed with a colourA shadow would trace the window's rectangle around an oval that has no edge — the one thing that
gives the illusion away. There is deliberately no NSVisualEffectView: its material is a
rectangle, and every way of masking one down to an ellipse leaves a faint straight edge somewhere.
The surface is painted entirely in SwiftUI, where a single elliptical mask is exact.
Two marks. Both are template-black at the point they meet AppKit, so the system owns their colour.
The menu bar mark (MenuBar/ContextMark.swift) — a round head, two oval eyes, two curved legs,
drawn as vectors on a 20 × 20 design box and rendered at 18 pt, the size it is displayed at. It is
NSColor.black with image.isTemplate = true, which is not a detail: a template image inverts
itself for a light or dark menu bar and dims with the status item when the app is inactive. It shipped
once as non-template clay and looked wrong on half the machines it ran on. MenuBarPresentationTests
asserts the template flag.
The eight-dot mark (OmiMark) — eight dots on a 260 pt canvas, centre 129.5, dot radius 17.2.
Dots 0/2/4/6 (N, E, S, W) sit at radius 86.71; the diagonals at 91.92. angle(i) = i·π/4 clockwise
from due north; direction(θ) = (sin θ, −cos θ). Each dot is a solid circle over a blurred glow
circle (blur 9·scale, alpha 0.3). It draws in primary by default, and templateImage(size:) is its
AppKit form.
The onboarding spotlight ring is drawn in white over a dark halo, because it lands on the menu bar — the system's own surface, routinely dark and routinely light, so nothing that is a single colour is guaranteed to show up there, the accent included.
Resources/ContextForClaude.icns still carries the old palette and is regenerated separately.
First person, lowercase ambition, no jargon, one thought per screen. The app asks in its own voice — "I would like to see your screen, so I know what you're working on" — and never explains itself twice. If a sentence could be deleted without losing meaning, delete it.
The app has worn three palettes. Each drop was deliberate.
The dark system — a #171716 ink surface, #FFFCEC cream type, a saturated nine-blob field,
Literata headlines over Inter body:
- The cream alpha ladder (0.08 / 0.35 / 0.50 / 0.55 / 0.70) and a separate white ladder for permission rows. Two ladders of translucency became one explicit three-step text hierarchy. An alpha is a value; a role is a decision.
- Literata and the serif/sans split. "First…" no longer contrasts with the step headline by face; both are Open Runde at one headline size.
- The −2.07 tracking on 46 pt Literata, a serif's signature. The hero keeps a signature at −0.035 em.
- The intro hero's drop shadow and the whole
InkTextShadowmechanism. There is no shadow anywhere in the system now. cursorBlue#96C4FFanderrorRed#FFB4AB, both tuned for a dark ground.NSVisualEffectViewbehind the card. This one did come back — see The glass. The original objection was right and is unchanged: a behind-window material takes its brightness from whatever is on the desktop. What was missing was the answer to it, which is a scrim thick enough that the material never sets the type's ground on its own, chosen by measurement rather than by eye.
The Anthropic system — ivory #FAF9F5 paper, #141413 ink, clay #D97757, New York display:
- The whole hand-mixed palette. It was another company's, it needed a light-only appearance override to hold together, and it was unreadable in Dark because the tones were fixed and the material behind them was not. System semantics replace all of it and are appearance-correct for free.
NSApp.appearance = .aqua. Deleted. See Appearance above.- The warm clay/manilla/kraft backdrop. Nine fixed warm tones became one neutral colour at nine alphas, which shades correctly in both appearances.
- New York, reached for as a stand-in for Tiempos. A borrowed serif is still borrowed.
- The clay, non-template menu bar mark. Back to template black.
- The onboarding sheet's card chrome in the menu bar — the filled row capsule
(
quaternaryLabelColor@ 0.5),.inkStyletracking, 42 pt button pills and hand-drawn hairlines. The popover is a menu now.