---
status: CURRENT (2026-10-09). The top-level UI design. It incorporates
`layout-architecture.md` (2026-08-11), whose sizing contract, personas and
decisions live here now, renumbered only where noted, and corrected to what is
built. The component documents below carry the detail. `shell-chrome.md` stays
the decision record for the chrome (D1–D13).
---
One shell for the Mac, the iPad and the iPhone, chosen by the shape of the window and never by the device, drawn by the app so that a Mac window and an iPad of the same size are the same pixels.
This document says how the whole thing fits together: the rules, the shells, the sizes and the contract every view obeys. Each part of the window has its own document:
| Document | What it covers |
|---|---|
| ui.md (this) | Principles, personas, the shells and how one is chosen, sizes, the text column, the chrome, the sizing contract, windows and presentations, validation, the layout decisions |
| primary.md | The primary sidebar (left, or the band on top) that shows the collections, and the compact shell's places |
| secondary.md | The secondary sidebar (right), the panel that shows what belongs to a note or a collection |
| toolbars.md | Every bar the app draws: the shell bar, headers, status bars, the find bar, the compact bars, sheet bars, and the formatting bar the app deliberately does not draw |
| tabs.md | Tabs: how they work today, and the design that replaces it (proposed) |
| menu.md | The menu bar on macOS and iPadOS, the command palette, and every shortcut |
| shell-chrome.md | The decision record for the chrome (D1–D12) and the survey behind it |
| wireframes.html | The original wireframes of 2026-08-11. Historical: they show the rail and inspector shapes that D2 and D6 replaced |
| implemented.md §17, §18, §51.4, §51.5 | How the layout, the chrome and the parity work was built, and what it found |
It started with one defect. A note's first lines could not be reached: no amount of scrolling brought them into view. Six speculative fixes failed because each treated it as a scrolling bug. Printing the running app's own view hierarchy found it with one measurement. Inside a 923pt window:
AppKitWindowHostingView h=923.0 y=0 ← the window
NavigationSplitRepresentable h=1477.5 y=-251 ← 554pt too tall
NSSplitView h=1477.5
column hosting view h=1477.5
MarkdownEditorView host h=1390.5 y=52
NSScrollView h=1390.5
The scroll offset was always correct. The view was taller than its window,
so its top 251pt sat above the frame, drawn nowhere and reachable by nothing.
NSScrollView, NSOutlineView, UITextView, WKWebView, PDFView and
QLPreviewView all report their content size as their fitting size, and a
representable with no sizeThatFits hands that to SwiftUI as an ideal size. A
2,000-note outline could inflate the shell on its own. The sizing contract (§8)
exists because of this, and every shell decision since has been expressed as
numbers first.
A second defect class drove the later work: the Mac and the iPad drifted apart. They drifted in which buttons existed, where the buttons sat, and the size and colour everything was drawn at, because two shells, two toolbars and the OS's own controls each gave a different answer. §7 is the result: one shell, drawn by the app.
Everything in the component documents derives from these. When a proposed change contradicts one, the change is wrong or the principle has to be revised here first.
- The shape of the window decides the layout, never the device (the axis of abundance). A Mac window and an iPad of the same size get the same shell. Stage Manager makes device identity meaningless, so it is never consulted.
- Three regions. The collections are on the left, the editor is in the
middle, and everything else is on the right (
shell-chrome.mdD6). A phone, which has room for one of them, shows the editor as the screen and the rest as places. - One row of chrome, ever (D1). No panel adds a row beneath the bar.
- Each command is in one place, and the place says what it is about (D13,
amending D8's "no command in the sidebar"; D14). The collection's commands
are the sidebar's — its tools (Mind Map, Assistant, Ask Your Library, Git)
as named rows above the tree, and making and opening (New Folder, New
Collection, Open Collection, Open Recent, Open Default Collection) under the
+in its header, beside its own toggle; the notes' are the bar's three buttons —+(every way to make a note), 🔍 (search, Open Quickly, find) and ⚙ Settings; the note's own are in Note Actions; and how the note is shown is in the bottom bar. People collapse the sidebar while working on a note, and nothing in it is a note's command; while it is hidden its toggle is the bar's first button, and every command is in the menu bar too. - Every command has a menu-bar item and a visible touch route. The bar is a shortcut and never the only way. Nothing is reachable only through a long-press, right-click or swipe (HIG, Context menus). iPhone has no menu bar, so the compact shell carries its own routes.
- An editor never blocks editing (D6a). The right panel is a column wherever the editor keeps its floor beside it, and never a modal over a note that could be typed in.
- The Mac and the iPad draw the same pixels (D12). The app draws its chrome
from
Chrometokens at the Mac's metrics, in the iPad's layout. The OS keeps only its window controls and whatever it presents once open: a menu, a popover, an alert. - A viewport reports the size it is offered, never the size it contains (S1, §8).
- The app opens no window of its own accord. A window exists because someone asked for one: New Window, Open in New Window, a link followed inside a note window (which opens the linked note in another), or About when no main window is in front. Everything else is a view in a region of the window you are in.
- Nothing takes focus that the user did not give it. A rebuild, a scan or a save may not move the caret, the selection or the note on screen.
Priority under pressure is the same for every persona (§3):
text > switching > list > library > references > properties
Navigation yields first. The editor is the last thing to shrink and the last to disappear.
| Persona | Context | Job | Cannot show |
|---|---|---|---|
| P1 Desk researcher | macOS, 1470–3840pt | cross-reference sources, write a synthesis, commit | — |
| P2 Laptop writer | macOS 860–1280, often half-screen ~660 | write beside a browser or a PDF | three columns and references at 660pt |
| P3 iPad reader | 11–13", Stage Manager = any width | read, follow a link, fix a sentence | three permanent columns in portrait |
| P4 Split capturer | iPad ⅓ (320pt) beside Safari | paste a quote, jot two lines | any second column, a status bar |
| P5 Phone capturer | iPhone 375–430pt | capture, look up, read one note | anything beside the note |
What each persona needs from the chrome, as opposed to the arrangement, is in
shell-chrome.md Part 1. The P2 corollary (principle 4) decides more of the
chrome than anything else.
shellKind(width:height:) (UI/Shell/ShellContract.swift) is the one rule. It
reads the window's size and nothing else:
| Condition | ShellKind |
Arrangement drawn |
|---|---|---|
width < 600 |
.compact |
Compact: places behind a bottom tab bar, the open note above it as a mini strip |
width ≥ 600 and height > width |
.tall |
Tall: the primary sidebar as a band across the top, the editor beneath, the panel as a column when it fits |
600 ≤ width < 960, landscape |
.two |
Column: sidebar · editor · panel |
960 ≤ width < 1400, landscape |
.wide |
Column |
width ≥ 1400, landscape |
.wideInspector |
Column |
There are five kinds and three arrangements. .two, .wide and
.wideInspector are drawn by the same columnShell. .two and .wide are
identical in every respect; .wideInspector differs only in the pane-width
estimate that seeds the environment (estimatedPaneWidth also subtracts the
panel's ideal width), and EditorPaneContainer replaces that estimate with the
measured width immediately. Whether the panel can be a column is decided by
ShellMetrics.hasPanelColumn, not by the kind (§4.4).
The scenes the contract is tested against (ShellContractTests):
| Scene | Size (pt) | Shell |
|---|---|---|
| Stage Manager tiny | 250 × 800 | compact |
| iPad ⅓ | 320 × 1024 | compact |
| iPhone SE | 375 × 667 | compact |
| iPhone 17 Pro | 402 × 874 | compact |
| iPad ½ | 507 × 1024 | compact |
| Mac half-screen | 660 × 900 | tall |
| iPad portrait | 834 × 1194 | tall |
| Tall Mac window | 900 × 1400 | tall |
| Mac minimum | 860 × 480 | two |
| Mac default | 1100 × 720 | wide |
| iPad landscape | 1194 × 834 | wide |
| Mac typical | 1470 × 923 | wideInspector |
| Mac ultrawide | 2560 × 1440 | wideInspector |
| Mac large | 3840 × 2160 | wideInspector |
Why "tall" exists. An iPad in portrait is 834pt wide. By width alone that buys a second column and a 554pt measure. Banding the navigation across the top gives the editor the full width and spends the height that portrait has spare.
Mac, 1470 × 923, sidebar and panel showing
┌────────────────────┬──────────────────────────────────────────────┬────────────────────────┐
│ ●●● + │ Search… ⊟ ✎ ⋯ │ Note A │ Note B │ ⌄ ◨ │ ▤ # ⇄ ◇ ↺ ◈ │ ⋈ ✧ ✦ ⊗ │ ← one 40pt row
├────────────────────┼──────────────────────────────────────────────┼────────────────────────┤
│ ▸ Recents │ (condition strip, only when needed) │ │
│ ▸ Bookmarks │ │ what the panel │
│ ▾ My Vault … │ Note title │ is showing │
│ ▸ Daily … │ The text of the note… │ │
│ ▸ Projects … │ │ │
│ A note │ │ │
│ 12:40 pm │ │ │
│ ▸ Obsidian Vault … ├──────────────────────────────────────────────┤ │
│ │ 3 changed [✎ 👁 ‹› ◫] │ 🔍 ☰ 🔗 ▤ 🧠 ✦ ↺ ⇪ ⧉ │ │ ← 44pt status bar
└────────────────────┴──────────────────────────────────────────────┴────────────────────────┘
280 (220–340) flexible, floor 320 360 (220 and up)
- The sidebar is column one: an
HStacksibling with aResizableDivider, not aNavigationSplitView(D3, as amended). → primary.md - The editor pane carries the shell bar over it (the 40pt row of commands and tabs) and, with a note open, the editor's status bar under it. → toolbars.md, tabs.md
- The panel is a sibling of the editor with its own divider, never
.inspector()(D7). → secondary.md - The sidebar header, the shell bar and the panel header are three 40pt bars side by side. Together they are the one row of chrome: the columns share one top edge.
- On the Mac the traffic lights sit over the sidebar's header (D11). With the
sidebar hidden the shell bar is the window's top-left corner and pads its
leading edge by
WindowControls.leadingInset(78pt on the Mac, 0 on iOS). That is the one number in the chrome that differs by platform.
iPad portrait, 834 × 1194
┌──────────────────────────────────────────────────────────────┐
│ + │ ← sidebar header, 40pt
├───────────────────────┬──────────────────────────────────────┤
│ ▸ Recents │ Meeting notes 12:40 pm │
│ ▾ My Vault … │ Reading list Mon │ band: 320pt in all,
│ ▸ Daily … │ Trip plan 2 Sep │ including the header
│ ▸ Projects … │ │
├───────────────────────┴──────────────────────────────────────┤
│ Search… ⊟ ✎ ⋯ │ Trip plan │ Reading list │ ⌄ ◨ │ ← shell bar, 40pt
├──────────────────────────────────────────────────────────────┤
│ Trip plan │
│ … │
├──────────────────────────────────────────────────────────────┤
│ status bar │
└──────────────────────────────────────────────────────────────┘
- The band is the primary sidebar split in two, Finder-style (D2a): containers on the left (260pt, draggable from 180 to the window width less 260), and what is directly inside the chosen container on the right. → primary.md
- The band is a fixed 320pt (
ShellMetrics.bandIdeal). It is shown and hidden by the same Sidebar button as the column. - The panel is a column beside the editor whenever the window is at least 540pt wide (320 of editor plus 220 of panel). That is every tall window, because a tall window is already 600pt or wider.
ShellMetrics.hasPanelColumn(kind:width:):
| Shell | Panel is a column when | Otherwise |
|---|---|---|
| compact | never | carried over the expanded note, or over the current place when the note is put away (InspectorOverlay) |
| tall | width ≥ editorFloor + panelFloor = 540 |
overlay (unreachable in practice) |
| column | width ≥ sidebarIdeal + editorFloor + panelFloor = 820 |
overlay |
The column test uses the sidebar's ideal width (280), whether the sidebar is showing or not and however wide it has been dragged. At the Mac's declared minimum of 860pt the panel is a column. With the sidebar dragged to its 340pt cap, the panel at its 220pt floor leaves the editor about 298pt: below its own floor, because the sidebar's range does not know about the panel (§12).
iPhone, 402 × 874 the note, expanded
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ status bar (the OS's) │ │ status bar (the OS's) │
├──────────────────────────────┤ ├──────────────────────────────┤
│ Library ⋯ │ 40 │ ⌄ Back Trip plan │ 40 ← expanded note's bar
├──────────────────────────────┤ ├──────────────────────────────┤
│ COLLECTIONS │ │ │ Trip plan │ Notes │ ⌄ ◨ │ 40 ← shell bar (tabs only)
│ 📚 My Vault 2,027 ✓ … │ ├──────────────────────────────┤
│ 📚 Obsidian Vault 81 … │ │ Trip plan │
│ │ │ … │
│ 🗃 All Notes ✓ │ │ │
│ │ ├──────────────────────────────┤
├──────────────────────────────┤ │ status bar (scrolls) │
│ 📄 Trip plan ⌃ │ 56 └──────────────────────────────┘
├──────────────────────────────┤
│ Notes Search Tags AI │ 49 + home indicator
└──────────────────────────────┘
- A bottom tab bar of four places: Notes, Search, Tags and AI. Each place
draws its own 40pt bar (
CompactPlaceBar). Places stay built once visited, as aTabView's tabs do, so each keeps its scroll position. - The open note is the now-playing track (L6): a 56pt mini strip above the tab bar. Tapping it expands the note over everything; its bar's Back collapses it. The strip and the tab bar are covered, not compressed (L11).
- Tapping a note in a place changes what the mini strip shows. It does not expand the note.
- The right panel has no column here. It is carried over the expanded note, or over the current place when the note is put away: 360pt wide, on a 12% black scrim that closes it when tapped. Putting the note away closes the panel too.
- The expanded note currently draws two 40pt bars, which breaks D1. See toolbars.md §8.4.
- The compact shell is not iPhone-only. A Mac window or an iPad window narrower than 600pt gets it too; on the Mac that happens only when the OS forces a window below its declared minimum.
→ The places themselves: primary.md §10.
Holding two notes is one job at three sizes. Wide: panes. Narrow: tabs, the only way to switch when a second pane cannot fit, so tabs matter more as the screen shrinks, not less. Extreme: the strip gives way to a menu. Switching is never removed.
Surplus width buys another note, never a wider line. The contract allows
maxPanes = min(4, pane width / 320), split manually only (L2): a wide display
opens with one pane, and the width only sets the ceiling. Panes are not
built. Today one pane holds the tabs, and the tabs are all there is:
tabs.md is their design, and its T9 is how switching survives at
every width. A way to switch between open notes must exist in every shell.
Every size the shell uses belongs in ShellMetrics (layout) or Chrome.Metric
(drawing). A number that is in neither is a number nobody decided: add it to
one of them before using it. The tables below are the ones a designer needs;
the rest are there too: insets (16pt of text inset per side of a pane),
noteRowTwoColumn (420, primary.md §4) and tallRailMin (900,
now read only by the pane-width estimate). Some sizes are still written in the
views instead (the status bar's 34pt row, the segmented control's 20pt
segments); §12 lists them.
| Region | Floor | Ideal | Cap | Source |
|---|---|---|---|---|
| Primary sidebar (column) | 220 | 280 | 340 | sidebarFloor/Ideal/Cap |
| Band (tall) | — | 320 tall | — | bandIdeal |
| Band's container pane | 180 | 260 | window width − 260 | bandContainerPane, BandTwoPane.paneRange |
| Editor pane | 320 (design target) | — | — | editorFloor |
| Right panel | 220 | 360 | window − sidebar − 320, but never below 220 | panelFloor/Ideal; see below |
| Window (Mac) | 860 × 480 | 1100 × 720 on first launch | — | windowMinWidth/Height, firstWindowSize |
| Note window (Mac) | 480 × 400 | system | — | NoteWindowView.representingNoteFile |
| Settings | 560 × 640 | AppSettingsView.size |
- Clamped at use, never forgotten. A dragged width is stored and clamped to
what the window can give each time it is used. Narrow the window and the width
is borrowed back; widen it and the width you chose returns
(
ResizableDivider). panelCap(560) is declared and read by nothing: the panel's upper bound is whatever leaves the editor 320pt, and its 220pt floor wins over the editor's when the two collide. Either the cap is applied or it is deleted (§12).- The editor floor is enforced by the declared window minimum, never by a
minWidthon the pane. Baked into the view, it makes the editor overflow a 250pt Stage Manager tile and clip its own text (decision L9).
| Element | Size | Token |
|---|---|---|
| Bar row: shell bar, sidebar header, panel header, place bar, sheet bar, note-window bar | 40pt | Chrome.Metric.barHeight |
| Bar button | 28pt drawn, with a 44pt hit area on every platform | control, touchTarget |
| Tab | 28pt; its target is its own frame | control |
| Segmented control | 20pt segments in a 24pt track, 10pt of extra hit area | written in ChromeSegmented |
| Bar padding · spacing · corner radius | 10 · 4 · 6pt | barPadding, barSpacing, radius |
| Search field in the bar | 120–180pt wide | searchWidth |
| Tab | up to 200pt wide | tabMaxWidth |
| Status bar (editor, collection) | 44pt: a 34pt row with 5pt above and below, under a rule | statusRow (the collection's); the editor's writes 34 itself |
| Compact tab bar | 49pt + bottom safe area | ShellMetrics.bottomTabBar |
| Mini strip | 56pt | ShellMetrics.miniStrip |
| Sidebar rows | note 32 · collection 24 · folder, place, file 22 | SidebarRowHeights |
| Row indent · selection inset | 14pt per level · 5 × 1pt | indent, selectionInsetX/Y |
| Resize grab area | 10pt around a 1pt line | ResizableDivider.grabWidth |
A bar button's drawing never grows for a finger. Only its invisible hit area does, which is how a touch-only iPad draws the same pixels as a Mac and still gets Apple's 44pt target. Tabs have no such margin; a segmented control's segments reach 40pt, short of 44.
| State | Scope | Key |
|---|---|---|
| Sidebar width, panel width, band container width | app-wide | sidebarWidth, sidePanelWidth, bandContainerPaneWidth (@AppStorage) |
| What the panel shows | app-wide | sidePanel |
| Whether the panel is showing | per window | inspectorPresented (@SceneStorage, default off) |
| Whether the band is hidden | per window | bandHidden |
| Whether the column sidebar is hidden | not remembered | columnVisibility (@State), see primary.md §12 |
| Open folders | per window | expandedFolders |
| Folded collections | not remembered | collapsedCollections (@State) |
| The sidebar's scope collection | per window | railPlace (primary.md §11) |
| Compact place | per window | compactPlace |
| The note and collection to reopen | per window | restoredNotePath, restoredCollectionID |
| Editor mode | app-wide, and synced between devices through iCloud | editorViewMode |
| The Settings page last shown | app-wide | SettingsPage.storageKey |
Every Appearance setting (theme, accent, text size, increased contrast, the
widths and the wrap guide in §6, the inline title, sort order) and the folder
conventions are app-wide and also sync between devices (CloudPrefs).
A pane has one text column, whatever mode it is showing
(TextWidth.column, TextColumn, MeasuredText). Two settings answer two
questions:
- Editor width is a proportion of the pane: Full pane (the default), 90%, 75% or 50%, never below 320pt unless the pane itself is narrower. Full pane is the whole pane — each view insets its text by its own amount inside it: Edit and Preview by 16pt (12pt on iOS), Markdown by 4pt. (It used to lose 16pt each side first as well, all of it on the right, which is where the editor's scroller sat: 32pt in from the window's edge, build 28.)
- Reading width is a maximum measure in characters: Narrow (60), Normal (80), Wide (90), or Full width (the default, no measure).
The column is the proportion capped by the measure. It is centred only when the measure is what bit; a column narrowed by Editor width alone stays left-aligned, VS Code style, because the pane is the workspace. The character width is always measured against the body font, never the monospaced one, so Markdown mode does not change the column's width.
The column is the text's, not the view's. Every view that shows the note —
Edit, Markdown, Preview — fills the pane, so its scroller is at the pane's edge
and a wheel anywhere over the pane scrolls the note; each lays its text out in
the column at its own width (TextColumn): the editors through their text
container's inset (TextPlacement), Preview through its page's
--hn-column-* margins, the inline title through TextColumnLayout. In Split
each half applies the column to its own width. The column used to be a frame
around the view, and the view's scroller went with it (implemented.md §51.40).
What changed from
layout-architecture.md. Decision 5 gave Reading one rule (80ch, centred) and Editing another (proportional, left-aligned). In practice the first thing switching Edit→Preview did was move the text sideways and re-break every line before a glyph had been re-measured. One column per pane replaced it, and both defaults are now Full.
Wrap guide (off, 72, 80 or 100 columns) is a line drawn in the editor, not a wrap point.
| Mode | What it shows | Editable | Shortcut |
|---|---|---|---|
| Edit | live WYSIWYG: markers revealed at the caret, blocks rendered | yes | ⌘1 |
| Preview | the note as it reads, GitHub-identical (GFMPreview) |
no | ⌘2 |
| Markdown | the source, monospaced, unstyled | yes | ⌘3 |
| Split | source and preview together, side by side or stacked by aspect | source side | ⌘4 |
The mode is one app-wide value (EditorMode.storageKey). The first note opened
into an empty window uses the platform default: Edit on the Mac, Preview on iOS,
because a note reached by tapping is usually one you meant to read. After that
the mode is inherited. An empty note always opens in Edit, on every
platform.
Every bar, row, control, form, sheet bar and empty state is drawn by the app
from Chrome tokens (UI/Shell/Chrome.swift, ChromeControls.swift,
ChromeRows.swift). The rule is that nothing in the chrome is a system toolbar,
List, Form, TabView, NavigationStack bar, text style, system colour or
material, because each of those is a different drawing on each platform.
.font(.body) is 13pt on the Mac and 17pt on iOS; a Toggle is 36×16 on one
and 51×31 on the other. The rule has known breaches, listed in §12: a few system
colours, the Multicolor accent, and headers inside three panel views.
| Token set | Holds |
|---|---|
Chrome.Typeface |
title 13 semibold · body 13 · row 12 · secondary 11 · group 11 semibold · row icon 12 · bar icon 14 · status 12 |
Chrome.Metric |
the sizes in §5.2 |
Chrome.Colour |
AppKit's values, both appearances: label, secondary, tertiary and quaternary label, separator, content (the editor) and chrome (the bars, sidebar and panel), fill, hover, a grouped form's fill and separator, a control's fill and its pressed fill, a switch's track when off, a field's border, and the thirteen system hues |
Chrome.selection(accent) |
a selected row, tab or button: the accent at 30% |
chromeDefaults(), applied at every window root by ThemedRoot, makes the
app's styles the defaults. An unstyled Button is therefore a push button, and
a row, card or glyph needs ChromePlainStyle.
What stays the OS's: the window controls (the traffic lights; iPadOS's own controls and resize grabber), the status bar, and anything the OS presents once open: a menu, a popover, an alert, the colour panel, the caret and selection. The button that opens a menu is the app's; the menu is the system's.
Measured by scripts/chrome-parity.sh and ChromeParityTests
(implemented.md §51.5):
- Font smoothing. The Mac put 12–19% more ink on every glyph.
Chrome.matchTextRendering()registersAppleFontSmoothing = 0for the app alone. - Line boxes. Each platform rounds a line box its own way (11pt is 14.0 on
the Mac and 13.5 on iOS).
.lineHeight(.leading(increase: 3))at the root makes every line its size plus 3, in whole points. Every vertical stack also names its spacing, because the default spacing is derived from each platform's rounding. - Baselines. Even where the totals agree, the Mac's baseline sits half a
point lower.
ChromeLinecentres on the capitals in a box oflineBox(size) = size + 3.
Text size. Chrome text scales by one factor, ChromeTextScale, driven by
the app's own Text Size on both platforms, with iOS's Larger Text on top. The
factor is exactly 1.0 at the defaults.
Accent. Settings ▸ Appearance offers eleven accents (AppearanceSettings.Accent).
Lavender, the default, is the brand colour. Blue, Purple, Pink, Red, Orange,
Yellow and Green are the Chrome hues, the Mac's values on both platforms.
Graphite is a fixed mid-grey, and Custom is any colour. Each is then lightened on
dark backgrounds and deepened slightly on light ones, so it stays legible in
either appearance. Multicolor follows the system: the Mac's accent colour on
macOS and the tint colour on iOS. It is the one accent that is not the same
colour on both platforms (§12). Accent on accent draws nothing, so a glyph
inside a selected row is drawn in the secondary label colour, never the tint.
Six layers; size information flows down only. A layer may report the size it was offered, never the size of what it contains.
Scene → Shell → Sidebar / Band → Pane → Viewport → Content
S1 — A viewport never advertises its content's size. Every representable
wrapping a scrolling or content-sized view implements sizeThatFits through
viewportSizeThatFits and never returns nil. nil means "ask the platform
view", which is the bug. Every SwiftUI ScrollView of rows gets .viewport()
(an ideal of 320 × 240 and no maximum).
| Proposal | Answer |
|---|---|
| concrete | exactly that |
.zero |
collapse |
.infinity |
grow |
nil |
a small constant, never the content's size |
S2 — A minimum is not a constraint. State a maximum too, or a large ideal
still inflates the parent (declaredWindowMinimum() pairs its floor with
maxWidth/maxHeight: .infinity). Breached by the note window's 480 × 400
minimum, which has no maximum (NoteWindowView.representingNoteFile).
S3 — Content expands, chrome is definite. Content gets
maxWidth/maxHeight: .infinity; chrome gets a definite height from §5.2.
S4 — Never nest an unbounded scroll inside a scroll.
S5 — Trust system safe-area insets. Never disable
automaticallyAdjustsContentInsets in a window with a toolbar. Disabling it
once hid the first 66pt of every note. The windows have had no system toolbar
since D12, and the editor now disables it on purpose, to add half a screen of
room past the end of a long note (MarkdownEditorView.updateScrollPastEndInset).
That is the switch S5 warns about, so any change to the window's chrome has to
re-check the top of a note.
S6 — Geometry never depends on the caret, the selection or the scroll position. Measured drift: a document's height changed from 3440 to 3516pt because the user scrolled.
Full detail and the viewport rules for the editor: implemented.md §17, and
the "viewport" rule in AGENTS.md.
| Scene | Platforms | What it is |
|---|---|---|
WindowGroup(id: "main") |
both | The shell (ContentView). File ▸ New Window (⌥⌘N) makes another. First size 1100×720; Mac minimum 860×480 |
WindowGroup(for: NoteRef.self) |
both | A single note in its own window (NoteWindowView): a 40pt bar the app draws with the note's title, then the note's editor and its status bar. No tabs, no sidebar, no panel. Opened by Open in New Window, and by following a link inside a note window |
Settings |
macOS | AppSettingsView at 560×640, ⌘,. On iPad the same view is a sheet (⌘, as well) |
MenuBarExtra |
macOS | Quick Capture from the menu bar. The same capture is a command on both platforms |
Both window scenes use .windowStyle(.hiddenTitleBar) on the Mac
(appDrawnTitleBar()) and ignore the hidden title bar's top safe area
(contentUnderTitleBar()), so the app's bar row is the window's top edge. The
main window is titled with the sidebar's scope collection for the Window menu and
Mission Control, and nothing draws that title (D10). (While an attachment is
shown, its viewer sets a title too, the file's name; which of the two the system
uses has not been checked.) A note window's bar draws its note's title.
The app opens no other window (principle 9). Graph, Mind Map, Ask Library and the Assistant used to be windows on the Mac and sheets on iOS. On iPadOS a scene replaces the notes in full screen and in Split View, and closing one left the app. The Graph is a view of the right panel — the open note's links in and out — and the Mind Map, Ask Your Library and the Assistant are the collection's, opened from the sidebar (on a phone, the Notes place's rows) as tabs beside the notes (D13).
| Kind | Rule |
|---|---|
| Sheet | chromeSheetFrame(width:height:): a fixed size on the Mac, the same size .fitted on iPad, the whole screen on iPhone. The intended top is a ChromeSheetBar (40pt, the title centred, the actions at either end). Eight sheets have one; the others open on a search field, a tab strip or a title of their own (toolbars.md §10) |
| Popover | states its compact adaptation (.presentationCompactAdaptation(.popover)); otherwise a compact width turns it into a sheet with a 360pt island floating in it. EditorToolbarContractTests checks this |
| Alert | the OS's (Rename Note, New Folder, confirmations) |
| Overlay | the splash (at launch it fades after half a second; from About it waits for a tap), and the panel where there is no column for it |
The sheets the shell presents: Settings (on the Mac, ⌘, and the AI-settings
routes open the Settings window; the other in-window routes open this sheet),
Command Palette, Open Quickly,
Review Links, New Note from a Prompt, Git settings, Clone, New Repository,
Welcome, Quick Capture, the launcher (Open…), Acknowledgements and cloud
collections. The editor presents its own: the diagram zoom, Slides, Rewrite Note,
Version History and Rewrite Selection. On iOS the Assistant presents its own
Settings sheet (AssistantHost). The picker that opens a collection is a sheet
on iOS and an NSOpenPanel run directly on the Mac. Clone and New Repository
show FolderPicker in a sheet on both, which on the Mac is an NSOpenPanel
inside a sheet of no size.
Correctness is measured, never eyeballed.
| Check | What it proves | Run |
|---|---|---|
ShellContractTests |
the shell rule over the scenes in §4.1; the sidebar is the only collapsible column and fits at the window minimum; the pane ceiling | ./scripts/run-tests.sh -only-testing:HelloNotesTests/ShellContractTests |
ShellViewportTests |
no view exceeds its scene or is stranded above it; resizing strands nothing; a viewport's ideal never depends on its content | same suite family |
ShellComplianceTests |
every platform gate has two branches; search is the bar's leading item; bar controls are labelled; New Note lands in the band's container; nothing in a shell re-implements shared logic | ./scripts/run-tests.sh |
PlatformParityTests |
every AppActions field is wired; every prompted field is labelled on both platforms |
./scripts/run-tests.sh |
ChromeParityTests + scripts/chrome-parity.sh |
the chrome (bar, rows, status bar, panel header, form, sheet bar, empty state, type) renders identically on the Mac and in the HN-iPad simulator, Δ≤12 per pixel and ≤0.1% of any scene |
./scripts/chrome-parity.sh |
scripts/window-parity.sh |
the whole window, iPad landscape against a Mac window of its exact safe area (1210×790) | needs the Mac's screen unlocked |
HelloNotesUITests |
screens actually draw on iOS: every compact place, four of the five Settings pages (not Support), the open note is not clipped, every reachable control has a name | xcodebuild test … -destination 'platform=iOS Simulator,name=HN-iPhone' -only-testing:HelloNotesUITests |
EditorToolbarContractTests |
the formatting commands are installed in the system's keyboard bar (iPad) and an accessory (iPhone) by both editors, the app draws no format bar of its own, every formatting button is named, and every popover in NoteEditorView and ContentView states its compact adaptation |
./scripts/run-tests.sh |
Scenes to check by hand after a shell change, beyond the table in §4.1: live resize 1470 → 320 → 1470, and Stage Manager at arbitrary sizes. Content extremes: an empty note, one line, 100k lines, a 2,000-note outline, a 4000px image or large PDF, a wide table, Dynamic Type XS to AX5, the keyboard up (about 350pt of editor left on a phone). Chrome must retract, not compress.
The contract is checked on viewports and their ancestors, never on scroll
content: being a window onto something larger than itself is what a viewport is
for. For live diagnosis, HN_GEOM_LOG=1 ./scripts/relaunch-debug.sh makes a
Debug build write the editor's ancestor chain to
~/Library/Containers/com.hellotham.HelloNotes/Data/Library/Caches/hn-geom.log.
Any ancestor taller than the window is a live S1/S2 violation.
Numbered as in layout-architecture.md, prefixed L, so code comments that cite
"decision 5" still find it. L15 onwards are the decisions made since.
| # | Decision | Status |
|---|---|---|
| L1 | Tags live in the right panel; the left is places only. Selecting a tag there filters the primary sidebar | ✅ The panel's Tags view; the phone's Tags place |
| L2 | Panes split manually only, up to min(4, pane / 320) |
⏸ Not built. maxPanes is computed and tested, and no view reads it. Tabs are the only way to hold two notes (tabs.md) |
| L3 | A persistent format bar at pane ≥ 560pt | ✗ Superseded by L20 |
| L4 | Columns are draggable and collapsible, remembered per window, clamped to the floors and caps | 🟡 Draggable and clamped. Widths are remembered app-wide, not per window. The band's collapse is remembered and the column's is not (primary.md §12) |
| L5 | Reading at a fixed measure, editing proportional | ↻ Replaced by one column per pane (§6.1) |
| L6 | Compact keeps the mini-note strip above the tab bar (the Apple Music model) | ✅ |
| L7 | Graph, Mind Map, Assistant and Ask Library as full-screen sheets on iPad and iPhone | ✗ Superseded by L16 |
| L8 | Note History is a panel view | ✅ |
| L9 | A hard window minimum; degrade if the OS forces smaller, never an error | ✅ 860×480 on the Mac; the compact shell below 600pt on either platform |
| L10 | The panel reopens on the view last used, remembered globally | ✅ sidePanel |
| L11 | Compact chrome retracts on scroll-down and returns on scroll-up | 🟡 The expanded note covers the chrome; nothing is linked to scrolling |
| L12, L13 | A 64pt rail of places; the library retracting below 960pt | ✗ Superseded by L14 |
| L14 | Collections and folders are one collapsible sidebar, Recents and Bookmarks pinned above them; the notes' commands in the bar, and from D13 the collection's own as named rows in the sidebar | ✅ (shell-chrome.md D2, D4, D8, D13) |
| L15 | In the tall shell the sidebar is two panes | ✅ (D2a) |
| L16 | The right panel holds the open note's six views, one panel, one state, one width; a column wherever the editor keeps its floor. It held nine until D13 moved the collection's three to tabs | ✅ (D6, D6a, D13) |
| L17 | The app opens no window of its own accord | ✅ (principle 9) |
| L18 | The chrome is the app's drawing; the Mac and the iPad are the same pixels | ✅ (D12) |
| L19 | One shell implementation, ContentView, for both platforms |
✅ (2026-08-22) |
| L20 | Formatting is the Format menu and the system's keyboard bar; the app draws no format bar | ✅ (toolbars.md §9) |
| L21 | Tabs are designed, not a side effect | 📝 Proposed in tabs.md |
- L2 means a wide display opens with one pane, and there is no second one.
- L4's gap is visible after a relaunch. If the sidebar was hidden, the column shell reopens with it showing while the bar still says Show Sidebar and keeps the traffic-light inset (primary.md §12).
- L9 is the only rule that permits violating a floor, and only when the OS forces it.
- L16 is why nothing ancillary is ever a sheet or a window: it has one place to go.
The component documents list their own. These cut across them.
-
✅
Contract values with no reader.ShellContext.tabBarHeight(withShellMetrics.tabBarPointer/tabBarTouch),keyboardAccessoryBar,statusBar(28pt; the status bars are 44pt),noteRowTouchMinimum/noteRowPointerMinimum,needsLibraryAffordance,maxPanes,panelCap,ShellKind.editorIsScreenandSidebarRowHeights.fallbackare declared, and some are tested, but no view reads them.ShellKind.hasSidebaris read only by tests, andShellContext.prefersTouchis published and read by nothing in the shell. A value with a rule and no reader reads as coverage and is worse than neither. Each is either wired or deleted, with its test. Fixed (implemented.md §51.36). -
The sidebar's range does not know about the panel. The sidebar may be dragged to 340pt whatever the panel is doing, and the panel test assumes 280. In an 860pt window with a 340pt sidebar and the panel open, the editor gets about 298pt, below its floor, and the bar's fixed items overflow it by 6pt (toolbars.md §3.1). One budget has to be shared by the three columns.
-
Breaches of D1 and D12.
- System colours:
Color.orangein the collection rows (ChromeCollectionRow) and.orangein the collection status bar, whereChrome.Colour.orangeis meant. - Multicolor resolves to each platform's own accent, so it is the one accent that differs between the Mac and the iPad (§7.2).
- Rows inside the panel: the Graph adds two (its distance strip and its counts-and-zoom row) under the panel's own header; the Mind Map and the Assistant draw a header at the top of their tabs (toolbars.md §11).
- Sizes written in views: the editor status bar's 34pt row and the
segmented control's 20/24pt, instead of tokens. The system orange is gone from the rows and the status bar, and a rule keeps it out (
ShellComplianceTests, implemented.md §51.36); multicolour, the panel's extra rows and sizes as tokens are design and stay.
- System colours:
-
Sheets have four different tops (toolbars.md §10). Eight use
ChromeSheetBar; the others open on a search field, a tab strip or a leading title of their own. -
Two tab systems on the Mac. The OS adds window tabs (View ▸ Show Tab Bar, Show All Tabs; Window ▸ Show Previous/Next Tab, Move Tab to New Window, Merge All Windows) beside the app's own. tabs.md T0 decides this.
-
Routes missing in both directions. Show/Hide Sidebar, Show/Hide Panel, most of the panel's views, New Folder and several row commands have no menu item. The Command Palette and Insert Template have no touch route. menu.md §8.
-
✅
Stale descriptions in the source.Comments that describe a design the code has left, so a reader believes the wrong thing:Where Says True now NoteOutlineItem.swift,SidebarTree.swift,NoteRowContent.swiftheaders;ContentView's doc comment abovesearchField(orphaned fromcollectionTree)an NSOutlineViewon the Mac and a SwiftUIListon iOSone drawn list on both ( NoteOutlineList)SidebarMenu.swift(the header andSidebarMenuItems' comment; two other comments nameSidebarItemRow)the Mac walks the items into an NSMenu; a view calledSidebarItemRowSwiftUI menus on both; SidebarItemRowdoes not existSidebarRowHeightsrow heights for heightOfRowByItem; a collection row's "close button"fixed heights for drawn rows; there is no close button NoteOutlineList.swift(the empty space below the rows)drops land there the empty space takes no drops AdaptiveShell.bandHidden;ContentView'scolumnVisibilityandexpandedFolderscommentsa toggle "for free" from NavigationSplitView; the shell overriding it below 960pt; an AppKit outline managing its own expansionthe app's own toggle; nothing overrides it; the shell holds expansion ContentView(theprefersTouchargument)pointer detection decides whether the format bar exists there is no format bar PointerPresence.swiftheaderthe tab bar's height and the note-row floor follow prefersTouchnothing reads either AdaptiveShell.estimatedPaneWidthandEditorPaneContainerthe pane width serves "the format-bar rule" there is no format bar EditorTabBar.swiftheadera tab's hit area grows for a finger a tab's target is its own frame CompactShell.swiftheaderthe Mac has no tagListoraiPlacefor its compact placesboth platforms have them AssistantHost.swiftthe Assistant is itself a sheet on iOS it is a panel view NoteEditorView.swift(the references popover)for shells with no panel "below 1400pt" the panel is a column from 820pt in a column window and 540pt in a tall one NoteInspector.swift(summarize)nilhides the button; the summary is written as a calloutthe caller never passes nil; the summary goes tosummary:SelectionActions.swift(namedSelectionActionBar.swiftin its header), and comments inContentViewandNoteEditorViewa floating bar under the selection on the Mac menu items in the editor's context menu. ( EditorHost's comment describes the menu correctly but citesSelectionActionBar.swift)GlobalHotKey.swiftheader,TerminationGuard.swift⌥⌘N summons Quick Capture ⌃⌥⌘N makes a new note AppCommands.swiftheaderMac-only commands are gated item by item no Mac-only command is gated (only the iPad's own Settings… is); a command that cannot run greys out AppCommands.swift(CloudBrowser.makeStore)the Mac opens four cloud-browser Windowsno such windows are declared AssistantHost.swiftheaderthe Assistant lives in a Windowscene on the Mac and a sheet on iOSit is a panel view on both CommandPalette.swift(templates)templates are reachable from the toolbar only the menu bar and the palette ContentView(the launcher)the launcher is on ⇧⌘O it is on ⌘O; ⇧⌘O is Open Quickly OpenQuicklyView.swiftheaderOpen Quickly is ⌘O it is ⇧⌘O AppCommands.swift(About, with no window)the new window shows the launch splash the splash shows once per launch; a later window shows none AppCommands.swift(insertTemplate)a template is inserted at the caret it is appended to the end of the note EditorTabs.totalLoadRevisiona tab is appended only after its note has been read the tab is appended first and fills in NavigationRouter.swift(Spotlight donation)a Spotlight result is a deep link back into the note nothing handles a Spotlight result LibraryPlace.swiftheader, andRailPlacea LibraryRail.swift;RailPlacesurviving only for the compact iOS shellno such file; RailPlaceis the sidebar's scope in every shellShellActions.movethe Mac's NSOutlineViewdropone drawn list AGENTS.md("The simulator tool's taps arrive as a mouse")the band switches to 24pt rows once a pointer appears row heights are fixed
| Term | Meaning here |
|---|---|
| Collection | An open folder of notes (a vault). Several can be open at once: the library |
| Focused collection | The one the window's commands act on: the collection of the note last opened, or the one chosen with Focus Collection. Its name is semibold in the sidebar |
| Place | A row that is not a folder on disk: Recents and Bookmarks in the sidebar; Notes, Search, Tags and AI in the compact shell |
| Primary sidebar | The left column, or the band in the tall shell (primary.md) |
| Band | The primary sidebar laid across the top of a tall window, in two panes |
| Panel / secondary sidebar | The right column that shows one of the open note's six views (secondary.md). Called the inspector in older code |
| Tool tab | One of the collection's tools — the Mind Map, the Assistant, Ask Your Library — open as a tab beside the notes (CollectionTool) |
| Pane | The editor's column. There is one; L2 would allow more |
| Bar | A 40pt row the app draws. The bar, or shell bar, is the one over the editor (toolbars.md) |
| Status bar | The 44pt strip under the editor, or under the empty editor |
| Tab | An open item in the bar's strip (tabs.md) |
| Mini strip | The compact shell's now-playing row for the open note |