Design system — rules for integrating designs (Figma MCP included)¶
SpacialShell is a macOS spatial window manager: Swift 6 / SwiftPM, SwiftUI hosted in AppKit
panels. There is no web stack — no HTML, CSS, stylesheets, npm UI packages, or token
transformation pipeline. A Figma design lands here as SwiftUI views in Sources/SpacialShellUI/,
styled with system materials and semantic colors, driven by pure view-state from
SpacialShellKit.
The authorities, in order: docs/superpowers/specs/2026-08-19-m2-shell-ui-design.md
("Design system (frontend-design pass)" section — its rulings are binding, don't re-open
them), then AGENTS.md (layering + PR media rules), then the code as it stands. Where the code
diverges from the spec today, that is a bug to close toward the spec, not a precedent
(see "Known divergences" at the end).
1. Tokens¶
Color: there is no palette. The OS owns appearance; the ruling is "the OS material is the palette". Binding assignments:
| Role | Value |
|---|---|
| Rail background | NSVisualEffectView(.sidebar) vibrancy (SwiftUI material until then) |
| Top bar background | NSVisualEffectView(.headerView), blendingMode: .behindWindow, state: .active |
| Active workspace / tab | controlAccentColor — 18 % fill on the rounded shape, glyph/text at 100 % |
| Inactive glyphs / text | secondaryLabelColor (SwiftUI .secondary) |
| Hover | quaternaryLabelColor |
| Hairlines | separatorColor (SwiftUI .separator) |
Light/dark and the accent come from System Settings. Never define a named color, an asset-catalog palette, a hex constant, or a theme switch. If a Figma file carries a color style, map it to the nearest semantic above or reject it.
Type (SF Pro, .system): 12 pt tab titles · 12 pt medium hover labels · 11 pt tooltips ·
9 pt semibold rounded "+N" badge · clock 11 pt medium monospaced, HH stacked over MM.
No custom fonts, no font config key — deliberately (see §Config below).
Space: 8 pt grid. Rail 48 pt wide (panel-width): 32 pt tiles, radius 8, 8 pt gaps,
16 pt between groups. A tile shows up to four app icons in a 2x2 grid plus its window count; the
app names, category and window previews belong to the hover popover, not the tile — the rail stays
narrow because it is furniture, and detail is one hover away. Top bar 34 pt tall (panel-height): 26 pt tabs, radius 6, 16 pt icons,
10 pt h-padding, tab width 88–220 pt; layout switcher 24 pt glyph, 12 pt from the trailing edge;
badge offset (+7, −7). Panels sit flush to screen edges. (Tried and rejected 2026-09-21: floating cards inset by a panel-margin, per claude/mockup-look — see #59. There is no margin knob, and the rail stays 48 pt: a wider rail to hold per-workspace layout schematics was rejected with it, #61.)
The only mutable "tokens" are config keys (Sources/SpacialShellKit/Config/Config.swift),
TOML, kebab-case:
public var panelWidth: Double = 48 // panel-width
public var panelHeight: Double = 34 // panel-height
public var railSide: RailSide = .left // rail-side: left | right
public var gap: Double = 8 // gap between tiled windows
Deliberately not configurable: theme, font, icon set. theme/font/icon-set keys once
existed as dead surface and were removed (gap-analysis spec §C: "they contradict the design's
own theming ruling"). Do not reintroduce them, and do not build a token-transformation layer —
Config → views is the entire pipeline.
2. Component library¶
Sources/SpacialShellUI/ — one view or controller per file. Current inventory:
| File | Role |
|---|---|
ScreenPanelView.swift |
Workspace rail (search glyph, tiles, trailing "+") |
WorkspacePanelView.swift |
Tab bar + layout switcher (bar set, active-outside, ⋯, cog) |
LayoutGlyph.swift |
A layout's glyph: its SF Symbol, or its zones outlined at 16 × 12 pt (#10) |
LayoutMenu.swift |
The switcher's ⋯ NSMenu: every layout, sectioned built-in / config.toml / drawn |
LayoutPopover.swift |
The tab-bar cog's popover (a non-activating panel) + its controller |
LayoutEditorView.swift |
The layout editor: presets, grid canvas, name/id, Copy as TOML (model: Kit GridEditor) |
LayoutsController.swift |
The editor window and the popover's settings.json edits |
OverviewView.swift |
Launcher overlay (search, windows + apps grid): a window is its cached thumbnail with an app-icon corner badge, or its icon alone without one (#189); apps are icons |
SpatialStripView.swift / SpatialController.swift |
The spatialisation view (#132): workspaces as mini-desktops, window chips at their layout frames, drawing the window's cached thumbnail under a material label band, or icon + title without one (#181) (model: Kit SpatialView) |
CheatSheetOverlay.swift |
Fn-hold keybinding sheet (view + controller) |
AppWindowSwitcherPanel.swift |
The app window switcher (#188): the focused app's windows as previews, held open on Fn+` / ⌘` (view + controller; model: Kit AppWindowSwitcher) |
PanelWindow.swift |
The non-activating NSPanel all chrome lives in |
ShellController.swift / OverviewController.swift |
AppKit owners: panel lifecycle, geometry, hosting |
RailHoverCard.swift |
Rail hover popover: window miniatures, or why there are none |
RailHoverController.swift |
Places the hover card beside the tile; owns its capture |
WindowPreview.swift |
ScreenCaptureKit per-hover window shots + the Screen Recording grant |
AppMetaCache.swift |
pid → app name + icon (NSRunningApplication) |
Every view is a pure function of state: props in, Command out. The canonical shape
(ScreenPanelView.swift):
struct ScreenPanelView: View {
let state: ScreenShellState // pure view-state from Kit
let launcherURL: String // config value, passed down
let send: (Command) -> Void // the ONLY way out
// ...
Button { send(.focusWorkspaceID(item.id)) } label: { ... }
Controllers (@MainActor final class) own PanelWindows and NSHostingViews, receive
update(world:) / update(config:) from AppRuntime, derive per-screen state, set
host.rootView, and forward send. Views never see World, controllers never mutate it.
3. Frameworks¶
- SwiftUI views inside
NSHostingView, inside borderless non-activatingNSPanels (PanelWindow.swift:canBecomeKey/Main = false,.floating,backgroundColor = .clear,isOpaque = false— the material is the only background; opaque panels render black). - SwiftPM only (
Package.swift), macOS 14+, Swift 6 strict concurrency (@MainActorUI,@Sendablesend closures). No xcodeproj, no storyboards, no xibs, no asset catalogs. - Target graph:
SpacialShellProtocol(wire types, leaf) ←SpacialShellKit(pure model, no AppKit) ←SpacialShellPlatform/SpacialShellUI←SpacialShellapp.SpacialShellUIdepends on Kit (+ Platform temporarily, forDisplayTopology.uuidonly). Kit must never grow an AppKit import.
4. Assets¶
There are no runtime image assets — icons are SF Symbols and live app icons (§5). The only
committed media is documentation/PR media in docs/media/:
- webp preferred, gif alongside for inline GitHub playback; stills webp/png. Loops ≲10 s, small — the existing showcase loops are the size reference.
Scripts/render-m2-media.pyregenerates the rendered loops/stills; new features extend it so the overview loop stays current. Renders must be labelled as renders, never passed off as captures.- Embedding (private repo): only
https://github.com/AskAlice/alice-material/blob/<branch>/docs/media/<file>?raw=true. Neverraw.githubusercontent.com,data:URIs, or?token=…links. - Snapshot references under
Tests/ShellStoryTests/__Snapshots__/are committed and double as PR media.
5. Icons¶
SF Symbols only. No icon fonts, no SVG imports, no bundled icon set (icon-set was removed
deliberately). Binding assignments from the spec:
| Use | Symbol |
|---|---|
| Search / launcher | magnifyingglass |
| Add / trailing empty workspace | plus |
| Rail tray (hidden windows + popups, #73) | tray.full |
| App menu | square.stack.3d.up |
| Layouts | rectangle · rectangle.split.2x1 · rectangle.split.3x1 · sidebar.left (half) · square.grid.2x2 (grid); a drawn layout without a symbol draws its zones (LayoutGlyph) |
| All layouts menu · layout popover | ellipsis · gearshape (the rail's gearshape is settings) |
| Missing layout badge | exclamationmark.triangle.fill, multicolor |
| Workspace tile | per rail-icon-style (#115, RailTile.face): app — up to four app icons, the workspace's symbol when it has none; category — the chosen symbol, else its category's (AppCategory.symbol); hybrid — that glyph over the top two app icons. Optional category-colors tint the glyph on inactive tiles |
| Category glyphs | globe web · chevron.left.forwardslash.chevron.right coding · terminal · bubble.left.and.bubble.right communication · play.rectangle media · paintbrush design · doc.text productivity · wrench.and.screwdriver utilities |
| Floating pin | pin.fill · close xmark |
| Wants attention (#126) | not a symbol: AttentionDot, a 7 pt systemRed circle ringed in the window background — top-right of a rail tile (opposite the count), on a tab icon's corner, or beside the title when the tab has no icon |
| Native-fullscreen tab | arrow.up.left.and.arrow.down.right |
| On-another-Space tab | macwindow.on.rectangle (not drawn on hidden tabs, which read as dimmed, nor on fullscreen ones, whose marker already says "own Space") |
| Pinned tab (#129) | pin.circle.fill, hierarchical, 12 pt — the circle keeps it apart from floating's bare pin.fill; tab menu pin.circle (Pin) · pin.slash (Unpin) |
| Placeholder tab (#128) | no glyph: the app icon and saved title, dimmed like a hidden tab, inside a 1 pt dashed (3/2) outline at the tab's radius in .tertiary — outlined where a window's tab is filled |
Window tabs and overview cells use real app icons via AppMetaCache
(NSRunningApplication(processIdentifier:).icon), cached per pid. Exporting icons from Figma is
wrong here — find the SF Symbol.
6. Styling¶
- SwiftUI modifiers only. Materials via
.background(.thinMaterial)today (target:NSVisualEffectViewper the §1 table); state-dependent fills viaAnyShapeStyleconditionals:
.fill(tab.isFocused ? AnyShapeStyle(Color.accentColor.opacity(0.22))
: AnyShapeStyle(Color.primary.opacity(0.001))) // ~clear but hit-testable
- Hover labels appear after 250 ms (
.onHover+NSTrackingAreaexit guard — hovers stick across screen edges otherwise). - Zen: panels fade 180 ms.
reduce-motion→ instant. There is no focus ring or glow (removed 2026-09-25, #60). - Light/dark come from the OS; every story is snapshot-tested in both (
.aqua/.darkAqua). - Copy: sentence case; verbs on controls ("Rename workspace…"); direct empty states. Raycast command names are Title Case (store rule).
7. Structure + Figma-integration rules¶
New UI must:
- Consume
ShellUIState, neverWorld. If the design needs data the view-state lacks, extendScreenShellState/ShellUI.state(for:in:)inSources/SpacialShellKit/ShellUI/ShellUIState.swiftfirst (pure, unit-testable), then render it. No AX,NSRunningApplication, or model spelunking in views — non-spatial lookups (names, icons) resolve platform-side by pid viaAppMetaCache. - Send
Commands, never mutate. Every interaction goes throughsend:intoWorldStore.run(_:), exactly like a hotkey. Purely-visual toggles (overview open/close) route through their controller, not the store. - Respect panel geometry from Config.
panel-width/panel-height/rail-side/gapare the layout inputs; the reconciler owns window geometry, insets are data (ShellInsets). Don't hardcode a Figma frame size where a config key exists. - Add a story. Every new view/state gets an entry in
Tests/ShellStoryTests/Stories.swiftat its true geometry (rail 48×800, bar 1200×34, or fitting size), which buys light+dark image snapshots and aLayoutLintpass (content fits, no text overlap/escape). Unhandled overflow states go in flaggedknownOverflow: true, not omitted.swift testis the gate. - Ship PR media per
AGENTS.md: screenshots + ≲10 s webp/gif loops of (a) the change and (b) the whole shell, inline in the PR body via theblob/<branch>/...?raw=trueschema; extendScripts/render-m2-media.pywhen the feature changes the showcase. - Never introduce a palette, theme, custom font, or non-SF icon. Map every Figma color to the §1 semantic table; every icon to an SF Symbol; every radius/spacing to the 8 pt grid. A design that can't be expressed that way needs a spec amendment, not a workaround.
Known divergences (code vs binding spec — close toward the spec)¶
- Both panels use SwiftUI
.thinMaterial; spec says rail.sidebar/ bar.headerViewNSVisualEffectViewwithstate: .active. - Active rail tile is
accentColor.opacity(0.85)+ white glyph (ScreenPanelView.swift); spec says 18 % accent fill, glyph at 100 %. - Tab titles are 11.5 pt (
WorkspacePanelView.swift); spec says 12 pt. Rail tiles 36 pt; spec 32. - Half-layout glyph is
rectangle.lefthalf.filled; spec assignssidebar.left. - Tab overflow scrolls instead of a "+N" badge: tabs squeeze to the 88 pt floor (focused tab +16 for its close button), then the row scrolls with the focused tab kept in view (#14). Scrolling the panels (#121) steps on the vertical axis only: one step per wheel notch or trackpad gesture, momentum swallowed. The rail steps workspaces, the tab row steps the focused tab (which the row then scrolls into view), and the layout icons cycle. A sideways gesture (trackpad, Shift-wheel) passes through untouched, so it still pans an overflowing tab row. Layout-switcher hover names are unbuilt.