Chrome customization
Theming paints the chrome palette and Settings
chrome toggles the top bar. This guide covers the
rest of NookConfiguration - the deeper seams a host reaches for when it wants
to ship its own out-of-box look, retune the chrome’s behavior and motion, or
brand the framework’s identity.
Every seam here is additive and non-breaking: each one defaults to a value
that reproduces the framework exactly, so a plain NookApp.main { MyHomeView() }
is unchanged. You opt in only where you need to. None of these are required to
ship a working notch app.
The working example for everything below is Examples/ChromeNook/main.swift.
When to reach for these
Section titled “When to reach for these”- Ship a non-default out-of-box look. A host that should launch translucent,
dark, or floating - before the user ever opens Settings - seeds that through
preferenceDefaults. - Change how the chrome behaves, not how it looks. Hover side-effects, the
cold-launch greeting, and the appearance-to-backdrop mapping live on
chromeBehavior. - Localize or rename chrome strings, or retune its fixed layout / springs.
labels,metrics, andmotioncover those. - Brand the framework’s identity. Name the product, swap the brand mark, or
drop the menu-bar item through
brandingandshowsMenuBarExtra. - Add top-bar actions or post status.
setTopBarTrailingItemsplants host glyphs in the top bar;AppState.showStatusdrives the framework banner.
Launch defaults: preferenceDefaults
Section titled “Launch defaults: preferenceDefaults”The process-global preferences - appearance (palette / surface style /
presentation / haptics / keep-open / accent / backdrop strength), the global
show/hide NookHotkey, and the
NookDisplayPreference - normally start from framework defaults until the user
changes them in Settings. preferenceDefaults reseeds those starting values, so
a host can launch with its own look and shortcut without the user opening
Settings first.
These are seed values, not overrides. The moment the user changes one of
them at runtime, that change is persisted and always wins; a seed value is
never written to UserDefaults. That distinction matters: revising a default
in a later build still reaches every user who never touched that setting, instead
of being shadowed by a stale persisted copy.
configuration.preferenceDefaults = NookPreferenceDefaults( appearance: NookAppearancePreferences( chromePalette: .followSystem, surfaceStyle: .translucent, presentation: .auto ))NookPreferenceDefaults carries three fields, each with a default that
reproduces the framework:
public struct NookPreferenceDefaults: Sendable, Equatable { public var appearance: NookAppearancePreferences // default .default public var hotkey: NookHotkey // default .default (cmd-opt-;) public var display: NookDisplayPreference // default .builtIn}Seed a different launch shortcut and display target the same way:
configuration.preferenceDefaults = NookPreferenceDefaults( hotkey: NookHotkey(keyCode: 49, carbonModifiers: 4096 | 2048, keySymbol: "Space"), display: .main // launch on whichever screen hosts the active menu bar)Appearance is kept field by field. When the person changes the palette, only the palette is stored; the surface style, the keep-open lock, and every other field they never changed keep following your defaults. So a host can move its default surface style to Liquid Glass in a later build and reach everyone who never picked a style, even people who turned on the lock. See Persistence for how records saved by earlier builds are migrated.
Settings’ “Reset All Settings” (AppCoordinator.resetAllSettingsToDefaults())
returns appearance, the shortcut, and the display to these defaults, not the
framework’s, and forgets the person’s choices so they follow your defaults again.
AppState also has resetAppearancePreferences(), resetHotkey(), and
resetDisplayPreference() for one preference at a time, and reads your defaults
back as preferenceDefaults.
On the single-module path this NookConfiguration.preferenceDefaults is
forwarded onto the synthesized host; a multi-module host sets the same value on
NookHostConfiguration.preferenceDefaults (see Multiple
modules). Because there is one AppState per
process, these are host-process-global either way.
Chrome behavior: NookChromeBehavior
Section titled “Chrome behavior: NookChromeBehavior”chromeBehavior describes how the single shared surface behaves - distinct
from the per-surface content and theme seams. Each knob defaults to today’s
framework behavior:
configuration.chromeBehavior = NookChromeBehavior( hoverBehavior: .all, // default []: opt into hover side-effects showsLaunchShimmer: false, // default true: launch silently backdrop: { context in .vibrancy(.init(material: .hudWindow, darkenOpacity: 0.3)) }, glassShading: .notchFade, // default .even: how the framework shades Liquid Glass companionBackdrop: nil, // default nil: what companions inherit keyboard: NookKeyboardBehavior(shortcutTakesKeyboardFocus: true) // default: shortcut only shows)glassShading and companionBackdrop are covered in
Surface materials, and keyboard in
Typing in the nook.
Hover behavior
Section titled “Hover behavior”hoverBehavior is a NookHoverBehavior option set, empty by default - the
framework applies no hover side-effects out of the box. Opt into either or both:
public struct NookHoverBehavior: OptionSet, Sendable { public static let keepVisible // hover keeps the surface visible past hide public static let hapticFeedback // a subtle alignment haptic on hover transitions public static let all // [.keepVisible, .hapticFeedback]}Applied when the surface is built. To change it later - say, from a “haptics on
hover” preference - pass a new NookChromeBehavior to
coordinator.replaceChromeBehavior(_:); the hover behavior applies from the next
hover, and a new backdrop resolver applies at once. See
Playground.
Launch shimmer
Section titled “Launch shimmer”showsLaunchShimmer defaults to true - the one-shot perimeter shimmer that
plays at cold launch. Set it to false for a silent launch; the chrome still
settles into its compact launch state, it just skips the feedback flourish.
Backdrop resolver
Section titled “Backdrop resolver”backdrop overrides how the chrome state maps to the surface backdrop.
nil (the default) uses the framework mapping - solid black or white for .solid
or Reduce Transparency, a .sidebar vibrancy with a legibility darken for
.translucent, and theme-tinted glass for .liquidGlass (see Surface
materials).
Supply a resolver to paint a brand-specific material, darken, or solid color
while still reacting to the live appearance state:
public typealias BackdropResolver = @Sendable @MainActor (NookBackdropContext) -> NookBackdropThe resolver receives a NookBackdropContext: the current
NookAppearancePreferences, the effective ColorScheme (after the host’s palette
override and the system scheme), whether Reduce Transparency is on, and what the
chrome is showing - its NookState and the NookChromeForm the presentation
resolved to on the current screen. It returns a NookBackdrop - one of
.vibrancy(_:), .solid(_:), or .liquidGlass(_:). This closure, not any
configuration struct, is where brand-tinted glass or a custom material lives:
configuration.chromeBehavior = NookChromeBehavior( backdrop: { context in if context.reduceTransparency || context.preferences.surfaceStyle == .solid { return .solid(context.colorScheme == .dark ? .black : .white) } return .liquidGlass(.init(tint: .indigo, tintStrength: 0.22)) })One chrome, two surfaces
Section titled “One chrome, two surfaces”The resolver is re-run on every expand and collapse, so context.state is all it
takes to paint the collapsed pill and the expanded panel differently. That matters
because they are not the same surface: collapsed, a notch-fused panel is the
hardware notch’s own height - 32 points on a 1728-wide built-in display - with
roughly three quarters of its width behind the camera, so a gradient sized for the
expanded panel only ever shades the two small wings either side of it.
configuration.chromeBehavior.backdrop = { context in switch context.state { case .expanded: .liquidGlass(.init(shading: .notchFade())) default: .solid(.black) // collapsed: read as the hardware notch }}context.form is the other half of the question - .auto resolves to
.notch on a notched display and .floating everywhere else, and a backdrop meant
to match the hardware is only right on the first. .hidden never reaches the
resolver on a transition: the chrome keeps its last backdrop across a hide, so
nothing repaints under a panel that is fading out. The same context reaches
companionBackdrop, so companions can follow the chrome state too.
A host that wants the black-at-the-notch look without writing a resolver can set
glassShading instead - .notchFade already does this, solid while collapsed and
faded while expanded. See
Surface materials.
NookChromeBehavior is not Equatable because backdrop carries a closure.
Like preferenceDefaults, it is host-process-global: a single-module host sets
it on NookConfiguration; a multi-module host sets it on
NookHostConfiguration.chromeBehavior.
Labels, metrics, typography, motion
Section titled “Labels, metrics, typography, motion”Four small value types tune the chrome’s strings, fixed layout values, fonts, and
in-panel springs. Each defaults to a value that reproduces today’s framework
exactly, and each reaches the views through a chrome environment value, so a host
sets only the fields it cares about. Compact slot content, expanded content, and
companions all get the same chrome environment: these four values, the resolved
theme and its accent tint, the module’s \.appServices, \.nookHostBranding,
\.nookChromeActions, and AppState as an @EnvironmentObject.
Labels
Section titled “Labels”NookChromeLabels holds every string the framework draws - for localization or
product naming (for example “Preferences” instead of “Settings”). The defaults are
today’s English. Four top-bar strings sit at the top level:
configuration.labels.settingsBreadcrumb = "Preferences"configuration.labels.keepOpenHelp = "Stay expanded after hover"configuration.labels.settingsHelp = "Settings"configuration.labels.dismissHelp = "Dismiss"settingsBreadcrumb is the label after the leading cluster
([icon] Title > Settings); the other three are tooltips on the keep-open lock,
the gear, and the banner’s dismiss button. The rest are grouped by where they
appear:
| Group | Strings |
|---|---|
labels.topBar |
The module switcher’s tooltip and its “needs attention” entry |
labels.settings |
The Settings group titles (Appearance, Display, Shortcut & nook, Data, About), the banner preview row and its message, “Reset All Settings”, the About version and fallback tagline |
labels.appearance |
The Theme, Surface, and Layout pickers, their options and captions, the accent swatch names, the strength slider |
labels.display |
The display picker, its options and captions |
labels.shortcut |
The global shortcut row, its hints and accessibility text, the “unavailable” message, “Stay expanded”, haptic feedback, “Sounds”, and the “Cycle Modules” shortcut name |
labels.menuBar |
The menu-bar item: Show, Settings…, Toggle Stay Expanded, Modules, Quit |
labels.components |
The NookComponents shelf and volume glyph |
labels.widgets |
A board’s empty state and its editor in Settings: the group title, caption, size menu, show switch, “Reset layout”, and the move actions |
labels.placeholderMessage |
The line on the placeholder home view |
configuration.labels.settings.appearanceTitle = "Look"configuration.labels.appearance.themeFollowSystem = "System"configuration.labels.appearance.accentNames[.teal] = "Petrol"configuration.labels.menuBar.quit = "Quit Constellation"A string that carries a value is a template: its {name} placeholders are filled
when it is drawn, so a translation can put the value where its grammar needs it.
Each template’s documentation lists its placeholders.
configuration.labels.shortcut.showHostFormat = "{host} anzeigen" // {host}configuration.labels.shortcut.unavailableFormat = "{combination} ist belegt." // {combination}configuration.labels.components.shelfFileCountOtherFormat = "{count} Dateien" // {count}Labels are drawn as written; the framework no longer looks its literals up in your app’s string tables, so localize by setting labels. The menu-bar item reads the active module’s labels. Labels are not part of a theme file.
Metrics
Section titled “Metrics”NookChromeMetrics exposes the fixed point values that were previously baked into
the chrome views. Defaults reproduce today’s layout:
configuration.metrics.edgePadding = 8 // panel edge to content insetconfiguration.metrics.compactSlotSize = 24 // square size of each compact pill slotconfiguration.metrics.breadcrumbMaxWidth = 140 // top-bar breadcrumb width before it fadesconfiguration.metrics.topBarHeight = 24 // fixed height of the top bar icon rowIt also carries the element-level sizes, corner radii, spacing, and the opacity multipliers layered on the resolved palette - the values the top bar, header icons, compact pill, and status banner used to hardcode. A few of them (every field defaults to today’s value, so set only what you want to change):
configuration.metrics.headerIconSize = 24 // lock / gear / home glyph frameconfiguration.metrics.headerIconCornerRadius = 7 // header-icon hover chip radiusconfiguration.metrics.trailingClusterSpacing = 4 // gap between trailing items / lock / gearconfiguration.metrics.brandMarkOpacity = 0.92 // leading brand-mark emphasisconfiguration.metrics.bannerCornerRadius = 10 // status banner corner radiusedgePadding is distinct from NookStyle.expandedContentInsets; host home views
should read the residual clearance through nookContentInsets rather than
mirroring it with extra padding. See Layout and content
insets and Examples/LayoutNook/main.swift.
Typography
Section titled “Typography”NookChromeTypography sets the fonts for the framework’s own text and glyphs.
Each role is a Font; defaults reproduce today’s sizes and weights. Because the
framework defaults carry no explicit design, the resolved fontDesign (see
Theming) still cascades over them - set .rounded once on the
theme and every role follows. This restyles the framework’s type only; a
registered home view, custom Settings, or trailing items control their own fonts.
Roles cover the chrome (top bar, compact pill, status banner) shown below, plus
the placeholder home, the built-in Settings panel (rows, pickers, the shortcut key
cap, the disclosure section), and the optional NookComponents shelf, activity
card, and volume glyph - so every framework-drawn surface is restylable. The same
applies to NookChromeMetrics, whose fields extend past the chrome into those
surfaces’ dimensions, radii, spacing, and emphasis opacities.
configuration.typography.headerIcon = .system(size: 11, weight: .semibold) // lock / gear / home glyphsconfiguration.typography.topBarLabel = .system(size: 11, weight: .regular) // title, breadcrumb, switcher labelconfiguration.typography.compactLeadingGlyph = .system(size: 10, weight: .semibold)configuration.typography.bannerMessage = .system(size: 10.5, weight: .medium)Motion
Section titled “Motion”NookChromeMotion retunes the chrome’s in-panel animation curves - distinct
from NookConfiguration.transitions, which governs the surface-level
expand / collapse / compact conversion. These drive the home-to-Settings swap,
the status banner, the breadcrumb, the leading cluster’s reveals, the Settings
groups, the switch between modules, and the activity card:
configuration.motion.viewModeChange = .snappy // home<->Settings swap and gear toggleconfiguration.motion.leadingClusterBack = .snappy // exiting Settings / clearing a breadcrumbconfiguration.motion.leadingClusterHover = .snappy // hover-reveal of the titleconfiguration.motion.statusBanner = .snappy // banner appearance / dismissalconfiguration.motion.breadcrumb = .easeOut(duration: 0.18)configuration.motion.settingsDisclosure = .snappy // a Settings group opening / closingconfiguration.motion.moduleSwitch = .easeInOut(duration: 0.22) // cross-fade between modulesconfiguration.motion.activityCard = .snappy // NookActivityHost card takeoverThe defaults are the curves these used to hardcode: settingsDisclosure is
.spring(response: 0.30, dampingFraction: 0.86), moduleSwitch is
.easeInOut(duration: 0.22), and activityCard is
.spring(response: 0.36, dampingFraction: 0.86). NookSettingsGroup and
NookActivityHost read the curve from the environment, so they follow it in a
custom Settings screen or home view too. Each field is also a theme token -
motion.viewModeChange, motion.leadingClusterBack, motion.leadingClusterHover,
motion.statusBanner, motion.breadcrumb, motion.settingsDisclosure,
motion.moduleSwitch, motion.activityCard - so a theme file can set them (see
Theming).
Engine-level shape and motion (NookSurface)
Section titled “Engine-level shape and motion (NookSurface)”These belong to the engine, one layer below NookConfiguration. The shape and
transition ones ride on NookStyle and NookTransitionConfiguration, which
NookConfiguration.style and .transitions already carry. The hover haptic, like the
chrome shadow, feedback style, and ambient wash,
is set on the Nook itself.
Corner radii
Section titled “Corner radii”NookStyle carries every radius the chrome draws with. Each defaults to the value
the chrome has always used:
var style = NookStyle(topCornerRadius: 19, bottomCornerRadius: 24)style.compactTopCornerRadius = 6 // the compact pill's ears (notch form)style.compactBottomCornerRadius = 14style.floatingExpandedTopCornerRadius = nil // nil: bottomCornerRadius on every cornerstyle.floatingExpandedBottomCornerRadius = nilstyle.floatingCompactCornerRadius = nil // nil: a capsule, half the pill's heightconfiguration.style = style // or nook.style on a Nook you driveA top radius is also that state’s horizontal padding, so it moves content in or out.
The outline
Section titled “The outline”The chrome draws, clips, hit-tests, and traces its rim, shadow, and feedback along one
NookShape. Its path comes from NookStyle.outline, a NookOutline:
style.outline = NookOutline(id: "squared") { geometry in Path(roundedRect: geometry.rect, cornerRadius: geometry.bottomCornerRadius / 2)}The chrome springs by interpolating the two radii and asking the outline for a path
each frame, so a custom outline animates as long as its path changes smoothly with
geometry.topCornerRadius and geometry.bottomCornerRadius. Keep it closed and
inside geometry.rect, since it is also the clip and the hover region.
NookOutline.standardNotchPath and standardFloatingPath are the built-in paths, to
start from. The id stands in for the closure in equality.
To draw a matching outline yourself, build a NookShape(form:topCornerRadius:bottomCornerRadius:outline:),
or read the live one inside compact and expanded content as \.nookChromeShape. It
describes the chrome’s frame, which is larger than the content’s by the horizontal
ear padding and expandedContentInsets.
Content transitions
Section titled “Content transitions”How compact slots and expanded content arrive and leave is a value on
NookTransitionConfiguration, alongside the curves:
configuration.transitions = NookTransitionConfiguration( compactContentTransition: .standardCompact, // blur 6, grow from zero width, fade expandedContentTransition: NookContentTransition(blurRadius: 0, scale: 0.9, fades: true))A NookContentTransition is a blur, a scale, and an optional fade. The scale runs
horizontally from the notch for compact slots and vertically from the top edge for
expanded content. .opacity is a plain fade and .identity none.
By default content leaves the way it arrived, on the surface’s curve, and arrives
with the chrome. A transition can name its own animation, and a delay holds the
content back after the chrome starts changing (at the transition’s start: faded,
blurred, and scaled). expandedContentRemoval and compactContentRemoval say how
content leaves when that differs; leaving never waits:
configuration.transitions = NookTransitionConfiguration( expandedContentTransition: NookContentTransition( blurRadius: 8, scale: 0.97, animation: .easeOut(duration: 0.3), delay: 0.16), expandedContentRemoval: NookContentTransition( blurRadius: 8, scale: 0.97, animation: .easeOut(duration: 0.16)))A delay with no animation of its own waits and then plays on the surface’s curve. A
theme sets the same through motion.content.enter, motion.content.exit, and
motion.content.enterDelay; see Choreography.
Which curve wins
Section titled “Which curve wins”NookStyle.openingAnimation, closingAnimation, and conversionAnimation are fixed.
NookTransitionConfiguration is the one override: a non-nil curve there wins, and a
nil one falls back to the style’s. NookKit always sets that configuration - to
NookConfiguration.transitions or its own default springs - so a NookKit host never
sees the style’s curves.
Hover haptic
Section titled “Hover haptic”With .hapticFeedback in the hover behavior, Nook.hoverHaptic picks the pattern:
nook.hoverHaptic = NookHoverHaptic(pattern: .levelChange, performanceTime: .now)The default, NookHoverHaptic.standard, is .alignment at the default time.
Identity: branding, brand mark, menu-bar
Section titled “Identity: branding, brand mark, menu-bar”NookHostBranding names the host product and supplies its brand mark - the
identity the chrome labels itself with. The About card reads hostName and
hostTagline; the show/hide hotkey label and the menu-bar fallback read
hostName; the mark closure replaces the OpenNook glyph everywhere the chrome
draws it (the top-bar leading cluster when no leadingIcon is set, the default
compact trailing slot, the placeholder home, the About card, and the menu-bar
status icon). The placeholder home also titles itself with hostName.
configuration.branding = NookHostBranding( hostName: "ContextNook", hostTagline: "A focused notch app.", mark: { size, color in AnyView(MyMark(color: color).frame(width: size, height: size)) })The brand mark is a NookBrandMark closure - @Sendable @MainActor (size, color) -> AnyView. It is handed a point size and the resolved tint, and the host returns
any SwiftUI view sized to fit. A minimal mark from Examples/ChromeNook/main.swift:
struct SparkMark: View { var color: Color var body: some View { Image(systemName: "sparkle") .resizable() .scaledToFit() .foregroundStyle(color) }}hostTagline is optional; leaving it nil falls back to the framework’s stock
line describing the host as built with OpenNook. Leaving mark nil keeps the
OpenNook NookMarkView - a filled notch cut from a pair of wings. NookHostBranding
is Equatable on its strings only (a closure can’t be compared), so two brandings
are equal when their hostName and hostTagline match.
The menu-bar status item draws the mark too. To give the menu bar an icon of its
own, set menuBarIcon - a NookBrandMark like the mark, drawn as a template image
so the menu bar tints it. NookHostBranding.symbol(_:) builds one from an SF Symbol:
configuration.branding = NookHostBranding( hostName: "ContextNook", mark: { size, color in AnyView(MyMark(color: color).frame(width: size, height: size)) }, menuBarIcon: NookHostBranding.symbol("sparkles"))To turn the framework’s menu-bar item off entirely - for a host that owns its own
menu-bar presence or wants none - set showsMenuBarExtra:
configuration.showsMenuBarExtra = falseThis drops the “Show …” / Settings / Quit fallback the framework otherwise
installs. Like the other host-level seams, on the single-module path
branding and showsMenuBarExtra forward onto the synthesized host; a
multi-module host sets branding on NookHostConfiguration directly (see the
host branding section).
Top-bar trailing items
Section titled “Top-bar trailing items”setTopBarTrailingItems registers host actions for the top bar’s trailing
cluster. They render immediately to the left of the framework’s keep-open lock
and gear, so the order reads host items, then lock, then gear. The items render
inside the same chrome environment as the rest of the top bar, so they can
observe AppState via @EnvironmentObject and read the palette via
@Environment(\.nookResolvedTheme):
configuration.setTopBarTrailingItems { ChromeTrailingActions() }
struct ChromeTrailingActions: View { @EnvironmentObject private var appState: AppState @Environment(\.nookResolvedTheme) private var theme
var body: some View { Button { appState.showStatus("Heads up - warning posted from the top bar.", severity: .warning) } label: { Image(systemName: "bell") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(theme.headerInactiveIcon) .frame(width: 24, height: 24) } .buttonStyle(.plain) .help("Post a warning status") }}Space is tight. The top bar runs under the physical notch on a notched display,
so anything between the notch’s edges is hardware-clipped, and the trailing
cluster has only roughly 80-100pt of usable width at the 480-520pt expanded
widths. Keep these to compact glyph-style buttons matching the lock and gear
weight - not wide labeled pills. Leaving trailingItems unset reproduces the
framework chrome exactly: just the lock and gear.
The lock and gear themselves can leave the bar: topBar.showsKeepOpenButton and
topBar.showsSettingsButton remove them without removing their features, and
NookKeepOpenButton, NookSettingsButton, and \.nookChromeActions put them
anywhere else - see Moving the lock and
gear.
Top-bar glyphs: topBar.symbols
Section titled “Top-bar glyphs: topBar.symbols”NookChromeSymbols names the SF Symbols the top bar draws. The defaults are
today’s glyphs:
configuration.topBar.symbols.keepOpenOn = "lock.fill" // lock while staying expandedconfiguration.topBar.symbols.keepOpenOff = "lock.open" // lock otherwiseconfiguration.topBar.symbols.settings = "gearshape" // gearconfiguration.topBar.symbols.breadcrumbSeparator = "chevron.right"configuration.topBar.symbols.back = nil // see belowconfiguration.topBar.symbols.moduleSwitcherIndicator = "chevron.down"configuration.topBar.symbols.moduleSwitcherActive = "checkmark"In Settings or under a module breadcrumb, the leading glyph becomes the back
control. By default it keeps the leading icon or brand mark, so the bar reads as a
breadcrumb ([mark] > Settings) rather than as back and forward buttons next to
the separator. Set back to draw a symbol there instead.
NookKeepOpenButton and NookSettingsButton read the same glyphs
(\.nookChromeSymbols), in expanded content and in the compact slots.
A custom leading icon
Section titled “A custom leading icon”topBar.leadingIcon takes an SF Symbol name. For anything else - an image, a
monogram, a live badge - draw the icon yourself with setLeadingIcon. The closure
receives the color to draw in: the idle icon color beside the title, or the back
control’s idle, hover, or active color.
configuration.topBar.setLeadingIcon { color in Monogram(color: color) }
struct Monogram: View { let color: Color
var body: some View { Text("C") .font(.system(size: 10, weight: .bold, design: .rounded)) .foregroundStyle(color) }}It wins over leadingIcon and the brand mark; with the in-surface module switcher
it is the fallback when no module is active.
Replacing the top bar
Section titled “Replacing the top bar”To draw a bar of your own without losing what the framework bar does, set
topBar.content - or call setTopBar - with a view built from a
NookTopBarContext. The context carries the bar’s state and the actions behind
its controls:
| Member | What it is |
|---|---|
title, leadingIcon |
leadingTitle for the current state, and leadingIcon |
viewMode, isSettingsShown |
Home or Settings |
showsSettings |
Whether Settings exists at all |
breadcrumb |
The module’s breadcrumb, or nil |
canGoBack, goBack() |
Leave Settings or clear the breadcrumb, as the leading glyph does |
isKeepOpen, toggleKeepOpen() |
Stay expanded, as the lock does |
toggleSettings() |
Home to Settings and back, as the gear does; nothing when showsSettings is false |
moduleSwitcher |
The modules, the active and attention ids, and switchTo(_:), for a multi-module host |
symbols |
The configured topBar.symbols |
configuration.setTopBar { bar in MyTopBar(bar: bar) }
struct MyTopBar: View { let bar: NookTopBarContext @Environment(\.nookContentInsets) private var insets
var body: some View { HStack(spacing: 8) { if bar.canGoBack { Button("Back", systemImage: bar.symbols.back ?? "chevron.left") { bar.goBack() } } Text(bar.breadcrumb ?? bar.title) Spacer() Button("Stay expanded", systemImage: bar.symbols.keepOpen(bar.isKeepOpen)) { bar.toggleKeepOpen() } if bar.showsSettings { Button("Settings", systemImage: bar.symbols.settings) { bar.toggleSettings() } } } .labelStyle(.iconOnly) .buttonStyle(.plain) .padding(.leading, insets.leading) .padding(.trailing, insets.trailing) }}NookTopBarContext is Sendable, so a view that keeps it can be registered like
any other content view.
The actions use the same NookChromeMotion curves as the framework bar. The host
bar renders in the chrome environment, so it can also read AppState,
\.nookChromeActions, the palette, the labels, and \.nookContentInsets - pad by
the insets’ leading and trailing to line up with home content the way the
framework bar does. It gets at least metrics.topBarHeight of height, which the
notch clearance below the bar assumes.
showsTopBar and showsStatusBanner still apply. The lock and gear visibility
flags, leadingIcon, trailingItems, and width describe the framework bar, so
they are yours to honor or ignore. moduleSwitcher is present when a multi-module
host sets moduleSwitcherPlacement = .leadingCluster, the placement that puts
switching in the top bar - which is now yours.
Top-bar width: .contentColumn
Section titled “Top-bar width: .contentColumn”topBar.width controls how the icon row spans the expanded content column. It
defaults to .contentColumn, which is usually what you want:
public enum Width: Sendable, Equatable { case contentColumn // default case intrinsic}.contentColumn(the default) gives the leading and trailing clusters the full column width, aligning trailing icons to the samenookContentInsetsgutter on the right that host home rows use. This is what makes your trailing items line up with content below them..intrinsicreproduces the legacy shrink-wrapped bar: the clusters shrink to their icons and center when narrower than the column.
configuration.topBar.width = .intrinsic // opt out of the content-column alignmentStatus banner
Section titled “Status banner”The framework renders a transient status banner under the top bar, driven by
AppState.status. Post to it from any AppState handle with showStatus:
public func showStatus(_ message: String, severity: NookStatusSeverity = .error)NookStatusSeverity has four cases - .error, .warning, .info, .success -
each selecting the banner’s SF Symbol (an error reads differently from a success).
The framework keeps the banner on its minimal palette: the glyph is tinted with
the resolved theme’s accent rather than inventing semantic red/green tokens, and
the severity distinction is carried by the symbol. A host that wants colored
severity supplies its own banner content or theme.
appState.showStatus("Imported 3 files", severity: .success)appState.showStatus("Could not reach the server", severity: .error)The status is tied to a single nook session - it is cleared on the next show/toggle, or by the banner’s dismiss button. (Durable failures, like a failed hotkey registration, use a separate channel so they outlive the session.)
To suppress the framework banner - for a host that surfaces status inside its own home content - turn it off:
configuration.topBar.showsStatusBanner = false // default trueshowStatus still updates AppState.status either way, so a host that renders
its own banner can read the message and severity directly from
@EnvironmentObject var appState.
See also
Section titled “See also”- Companion surfaces and Rim glow and edge fade - the configuration seams that float host views beside the nook and add effects to the panel itself.
Examples/ChromeNook/main.swift- the working example this guide mirrors: launch defaults, chrome behavior, labels/metrics/motion, a custom brand mark, trailing items, and the status banner in one host.- Settings chrome - the top-bar visibility flags
(
showsTopBar,showsSettings) and the leading cluster. - Theming - the chrome palette,
accent, andfontDesignthat these seams render against. - Multiple modules - where
preferenceDefaults,chromeBehavior, andbrandinglive onNookHostConfigurationfor a multi-module host.