The single source of truth for LockIME's visual & interaction design. Grounded in Apple HIG (macOS 26 / Liquid Glass) and the patterns of top menu-bar apps (Ice, Loop, Stats, Bartender, System Settings, Apple Software Update).
Deployment floor: macOS 14.0 (anchored by
@Observable; going lower means rewriting the observation layer). "Tahoe first-class" is a design target, not an API dependency: the glass aesthetic comes from the system rendering standard controls on macOS 26. Newer-OS API is allowed solely behind#availablewith a sane fallback — currently two cases: thedsGlass*ButtonStyle()helpers inDesignSystem.swift(26 → bordered styles) and the settingsTabbuilder + Updates badge inSettingsRootView.swift(15 →.tabItem, no badge). Everything else must compile against the 14.0 target, which the compiler enforces. Pre-26 the app renders with standard Sonoma/Sequoia materials.
LockIME is a calm, native, system-native security utility that looks like it shipped with macOS 26. If it isn't a System Settings pane, an Apple Software Update sheet, or the system volume HUD, we don't ship it.
- Liquid Glass is navigation-layer only (update-window action buttons, the menu-bar popup chrome the OS gives us for free). Never glass on content (Forms, changelog, log rows, rule rows).
- Hand-roll nothing the OS supplies: semantic colors, system materials, SF Symbols drive light/dark/accessibility adaptation automatically.
- Brand expresses itself through exactly three levers: one indigo accent, one sharp full-bleed app icon, one signature lock/unlock symbol transition.
- Never hardcode
.white/.black/RGB. Never import iOS 17pt type — macOS body is 13pt.
Caseless namespaced enums. Reference tokens everywhere; never inline literals.
Inside .formStyle(.grouped) Forms, add no spacing/padding — the Form owns its
insets. Tokens apply to custom views (About, Update, picker).
xxs 2 · xs 4 · sm 6 · md 8 · lg 12 · xl 16 · xxl 24 · section 32
control 6 · row 10 · panel 12 · sheet 16 · capsule for confirmation/glass.
For nested custom containers prefer RoundedRectangle(cornerRadius: .containerConcentric).
.tint() alone does not reach AppKit Picker/checkbox/focus-ring on macOS,
and App/URL Rules use Pickers heavily — so the asset + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME=AccentColor is mandatory.
| Appearance | Hex (sRGB) |
|---|---|
| Light | #3A5BD9 |
| Dark | #5B7BF0 |
| Light · Increase Contrast | #2A46B8 |
| Dark · Increase Contrast | #7E98FF |
Accent only on: the locked state, the one prominent update button, links.
Destructive (trash) = Button(role: .destructive) system red. Status semantics
(success .green, error .orange/.red) confined to update-window results.
appName .title semibold · windowTitle .title2 bold · rowTitle .body ·
version .callout · rowSubtitle .caption2 · sectionFooter .footnote ·
copyright .caption. Foreground ramp: .primary · .secondary · .tertiary ·
.quaternary.
Settings content: no material. Transient confirmation / About panel / Update
backdrop: .regularMaterial. Update action buttons: .glassProminent (one
primary) + .glass (secondary). Never NSVisualEffectView by hand; never
.clear glass; never glass-on-glass without GlassEffectContainer.
toggle .spring(response:0.3, dampingFraction:0.85) · list .smooth(0.25) ·
confirmIn .easeOut(0.18) · confirmOut .easeIn(0.22) · dwell 2.0s.
One signature moment only: .contentTransition(.symbolEffect(.replace)) on
lock.fill↔lock.open. Gate manual springs on accessibilityReduceMotion.
Classic 10-image macOS .appiconset, not Icon Composer .icon (headless/CI
friendly). The source master stays full-bleed, but the shipped appicon PNGs are
composed onto the standard macOS icon grid — an ~824px rounded body inset on
a transparent margin (the central ~80.5% of the 1024 canvas) with a soft system
drop shadow — so the icon renders the same size as every native app and never
exposes raw square corners. A full-bleed PNG renders ~24% larger than its
neighbours on older Launchpad/Finder surfaces that draw the resource verbatim.
macOS 26 (Tahoe) instead re-normalizes legacy icons — it scales the opaque
art to its own uniform grid and re-masks — so it caps the old full-bleed PNG to
the right size at draw time but renders an under-filled body too small; the fix
is to match Apple's own on-disk geometry exactly (body 824, opaque bbox ≈842/1024
incl. shadow, measured from /System/Applications/*.app), which then tracks the
system icons on both the verbatim (≤15) and normalizing (26) paths. Verified
on Tahoe: the composed icon renders at the same 844px/82.4% as Calculator.
.icon (Icon Composer / full Liquid Glass) deferred as future polish.
- Master 1024×1024, PNG-24, sRGB, opaque, FULL-BLEED source art. Do not
add a gutter, gloss, bevel, or shadow — the generator adds the margin, mask,
and shadow. Glyph within ~10% safe inset (central ~820²). The shipped body is
inset to
824/1024of the canvas (margins matched empirically to/System/Applications/*.app) and rounded atcornerRadius ~= body * 0.225. - Required 10 images (idiom=mac): 16, 32(16@2x), 32, 64(32@2x), 128, 256(128@2x), 256, 512(256@2x), 512, 1024(512@2x). All ten — one PNG → empty plist → grey generic icon.
- Pipeline:
scripts/MakeIcon.swift(SwiftUIImageRenderer) renders the 1024 master when the committed raster master is absent;scripts/icon-tools/ComposeAppIcon.swiftcomposes the grid-correct 1024 (inset body + mask + shadow);sipsdownscales that single 1024 into every size; hand-writtenContents.json. Cache-bust when verifying:sudo rm -rf /Library/Caches/com.apple.iconservices.store; killall Dock. - Visual: opaque diagonal indigo→blue (
#2A5BE0→#1840B4); near-white#F5F7FFclosed padlock with a subtle IME affordance (文/A monogram or caret); flat/clean, at most a faint top inner highlight. Keep the blue art full-bleed in the master; the generator insets it onto the icon body. Do not bake the margin, shadow, or new-system glass into the master PNG. - Wiring:
project.yml→ targetresources: - path: Sources/LockIME/Assets.xcassetsandASSETCATALOG_COMPILER_APPICON_NAME: AppIcon. Do not addCFBundleIconName/CFBundleIconFileto Info.plist — actool injects them.
Bar glyph: template lock.fill/lock.open, swapped by state, optional one-shot
.symbolEffect. Header: a padlock glyph (lock.fill when locked,
lock.open.fill when unlocked) plus the lock state word only ("Locked" /
"Unlocked"), with the configured global toggle-lock shortcut echoed on the right.
The current source name is deliberately not repeated here — the list below
already marks the locked source with a checkmark. It's a disabled Button:
a Label alone won't render a menu accelerator, but a disabled Button draws the
key-equivalent glyphs natively while never firing them, so it stays a pure hint
with no clash against the real global handler. The shortcut is read from
AppState.toggleLockShortcut (mirrored from KeyboardShortcuts and kept in sync
via the library's shortcutByNameDidChange notification — a plain getShortcut
read isn't @Observable-tracked, so the header wouldn't refresh when the user
binds/clears it in Settings). It's mapped through menuDisplayShortcut, a
best-effort KeyboardShortcut for single printable keys (any modifiers, up to
"⌃⌥⇧⌘X"); exotic keys (Space, arrows, F-keys) still work globally but aren't
echoed. The system input sources are flattened directly into the
menu, bracketed by one divider above and one below — no master toggle, no
submenu. Each source is a Button carrying a leading checkmark image (the
CheckmarkSlot NSImages), shown on the locked one and a same-size transparent
slot otherwise; a source is checked iff locking is on and it is the global
target (config.defaultSourceID). The image (not a Toggle's native checkmark,
which lives in NSMenu's state column and collapses to zero width when nothing
is checked) keeps the gutter reserved at a constant width, so the menu never
grows or shrinks as the lock toggles — and NSMenu drops SwiftUI's .opacity on a
Label's system-image icon, so the slot must swap the image itself, not hide a
symbol. Clicking an unchecked source locks to it (AppState.lockToSource sets
the target and enables locking in one commit, re-resolving and flipping the
active source immediately); clicking the checked source disables locking
(setMasterEnabled(false)), leaving the target remembered. This is the same
write path as the App Rules "Global default" picker. Source names are
Text(verbatim:) system strings, not catalog keys. The
engine keeps the list live via a second
InputSourceChangeObserver(.enabledSourcesChanged), so adding/removing an input
source in System Settings updates both this menu and the Settings pickers. The
global toggle-lock shortcut (Settings ▸ Shortcuts) is unchanged — it flips
locking on/off against the remembered target, independent of this list.
Collapse Settings / Check for Updates / About into one Section (fewer dividers).
Keep .keyboardShortcut hints. Zero custom color — NSMenu supplies everything.
(Active-scope .badge is a nice-to-have — verify it renders on the real macOS 26
build before relying on it.)
No sidebar (.sidebarAdaptable breaks ToolbarSpacer on macOS). Frame
minWidth 680, idealWidth 700, minHeight 600, growable. (minHeight sized so the
tallest pane — General, now with the Automation ▸ URL Scheme API toggle —
fits without a scrollbar even at the minimum window height.) .scenePadding() at
window level; panes own internal insets — verify no double-padding. Tab
selection is bound to AppState.settingsTab (so a feature pane can route the
user to Permissions for the single Accessibility grant); the root view's
onDisappear (window close, not a tab switch) is the abandon signal that stops
the grant watcher.
- General: master toggle (
withAnimation(DS.Motion.toggle)+.contentTransition(.symbolEffect(.replace))on the lock label), current source- activation count via
LabeledContent, launch-at-login, language.
- activation count via
- App / URL Rules: rows via shared
AppRowLabel(bundleID:)(icon 22 + name.body+ bundle ID.caption2 .secondary). Empty state =ContentUnavailableViewwith an action. Rows.transition(.move(edge:.top) .combined(with:.opacity)); wrap upsert/remove inwithAnimation(DS.Motion.list). Each source-pinning rule carries a lock vs switch action: lock (.locked/ URL.lock) continuously re-applies the source while the rule is in force; switch (AppRuleMode.switched/ URLRuleAction.switchOnce) fires a one-shot switch on entry and then releases, so the user may change the source and is never reverted. App rules express it as a fourth mode-picker option ("Switch to", beside Lock to / Ignore / Use default); URL rows carry a small Lock to / Switch to picker. The global default stays lock-only. The one-shot's fire-exactly-once-per-entry is owned byLockEngine(an in-memory transition key, with a separate slot for a launcher overlay's own switch so an excursion never re-yanks the underlying app); the kit'sLockController.switchOnceperforms the switch without installing a standing target. - Shortcuts: native recorder rows in two sections — Global (toggle lock,
lock to previous/next input source) and Current app (cycle, or remove, the
frontmost app's rule). Recorder titles must be
LocalizedStringKey(...), not a bareStringliteral, or the label renders in the system language (see the i18n guards in CLAUDE.md). - Permissions: the single home for the optional Accessibility grant
(
AXIsProcessTrusted), which unlocks two features — per-URL rules and launcher-overlay detection (Spotlight/Raycast/…). OneGrantAccessibilityButton(shared inComponents.swift) lives only here; App Rules and URL Rules show a passiveAccessibilityRequiredNotethat routes here, so the permission reads as one capability with a single grant, never a prompt duplicated per feature. The core lock stays permission-free. - Updates:
LabeledContent"Last checked: …" ("Never" fallback), Check button, inline up-to-date/error result (see 4.6), badge the tab when an update is available. - Log: plain rows on content background;
.controlSize(.small)dense; no glass.
Shared layer in Sources/LockIME/UI: AppRowLabel, SectionFooter(_:), tokens.
Frame 340 wide, content-driven height (~330), non-resizable, no toolbar/title.
Background .regularMaterial. Rhythm: 28 top · icon 128 (NSApp.applicationIconImage)
· 14 · name .title semibold · 2 · "Version x (y)" .callout .secondary
selectable · 12 · tagline .subheadline .secondary · 14 · links row · Spacer ·
copyright .caption .tertiary · 20 bottom. Links (.buttonStyle(.link)): GitHub,
Website, Acknowledgements (sheet listing Sparkle / swift-markdown-ui /
KeyboardShortcuts licenses).
Fixed 540×480, non-reflowing across phases. Header: real app icon 52pt
(not the lock SF Symbol), title .title2.bold "Software Update", subtitle
.subheadline .secondary "Version X is available", 20pt padding, Divider.
Body: ScrollView + Markdown .gitHub ~13pt, 16–20pt padding, empty/long handled.
Footer: 16–20pt padding, Divider above, determinate ProgressView(value:) ~220pt
or small spinner; trailing buttons 8–12 gap: primary .glassProminent
("Install Update" → "Install and Relaunch"), secondary .glass "Later", tertiary
link "Skip This Version" on .found only (one-shot reply guard). Result states
centered with semantic color + text.
Searchable list, rows reuse AppRowLabel (icon 32). Sorted by name. Standard sheet
footer (Add / Cancel). No glass.
The black capsule is the single most off-brand element (ignores light/dark, accent, Reduce Transparency). Replace with:
- User-initiated up-to-date/error → native
NSAlert(app icon, single OK, fully adaptive) and update the "Last checked" timestamp; when the Updates pane is open, also reflect the result inline next to the Check button. - Scheduled/background find → gentle, non-modal:
supportsGentleScheduledUpdateReminders = true; post aUNUserNotification+ badge the Updates tab. Never auto-open the window for background checks or "no update".
| Question | Decision |
|---|---|
| Toast | Delete; NSAlert + inline Updates result + "Last checked" |
| Settings nav | Keep top TabView (7 tabs incl. Permissions), widen to 680 |
| Icon format | .appiconset PNG set, full-bleed source, pre-masked shipped PNGs |
| Accent delivery | Asset-catalog AccentColor as Global Accent |
| Update header art | Real app icon, not lock SF Symbol |
| Menu style | Native .menu |
| About icon | 128pt |
| Glass scope | Update buttons + transient confirmation only |
| About/Update window host | Keep HostedWindowController — SwiftUI Window scenes opened from an LSUIElement menu fall behind other windows (project-verified P11 bug). Give the hosted window Tahoe styling itself. |
- Opaque full-bleed appiconset PNGs expose square corners on older Launchpad. Keep the source full-bleed, but ship the masked PNGs in all 10 sizes.
- Icon caching hides rebuilds — bust the iconservices cache +
killall Dock. - No manual
CFBundleIcon*keys — actool owns them. .tint()no-ops on macOSPicker— use theAccentColorasset (app target, not the SPM module).- No glass on content; never animate persistent glass (battery for a bg app).
- macOS body 13pt, not iOS 17pt. Verify no double-padding (scenePadding + Form).
- NSMenu
.badge/Section SwiftUI bridge is historically quirky — verify on device. - Removing the toast must not leave the user-check silent.
- "Skip This Version" only on the found prompt; extend the one-shot-reply guard.