Playground
Examples/PlaygroundNook is a live customization playground. A controls window
beside the running nook changes its appearance, theme, size and shape, type and
motion, top bar, companion surfaces, rim glow, scroll edge fade, and hover
behavior - and, on the Tokens page, any single theme token - and the real nook - not
a mock - updates as you go. When it looks right, copy the result as Swift that sets
only what you changed, save it as a JSON preset to share or open later, or copy the
theme alone as a theme file.
swift run PlaygroundNookThe window
Section titled “The window”The playground opens a regular, resizable window next to the nook. The nook starts as the stock chrome, so everything you see change is something you changed.
- The sidebar lists the pages. A dot marks a page with changes, and right-clicking a page resets it.
- At the top right, Keep Open holds the nook open while you work in the window, Expand opens or collapses the nook, and Code shows or hides the code.
- The code on the right is the Swift snippet or the JSON preset for what you have now, updated as you change it, with a button to copy it.
Each page groups its settings into cards, with one control column so every control lines up. Settings most people leave alone - content insets, metrics, extra theme colors, the chrome’s labels - are folded into a row until you open it; a dot on the row means something inside has changed. Hover a label or an info icon to see what it does. A changed value shows a reset arrow beside its label, a changed card shows Reset, and applying a preset or resetting a page can be undone from the confirmation at the bottom of the window. The window follows the Mac’s light or dark appearance.
The controls are in a window rather than in the nook itself: the nook changes shape as you edit it, collapses when the pointer leaves, and has no room for text fields and color pickers. While the window is open the playground is a regular app with a Dock icon and a menu bar, so Command-Tab reaches it and text fields get the usual editing shortcuts. Closing the window returns it to a menu-bar-only app; the Controls button in the nook brings the window back.
Two launch options help when you try presets:
swift run PlaygroundNook --preset my-preset.json # open a preset at launchswift run PlaygroundNook --sample glass # media, glass, glance, call, or defaultsswift run PlaygroundNook --expand # open the nook at launchswift run PlaygroundNook --keep-open # hold the nook expandedswift run PlaygroundNook --hide-controls # skip the controls windowWhat you can change
Section titled “What you can change”| Page | Changes | Lands on |
|---|---|---|
| Appearance | layout, palette, material, backdrop strength, accent, haptics; under Opening, what hovering the pill does and its waits | AppState.appearancePreferences |
| Theme | font design and width; corner, scale, and motion knobs; accent, label, fill, stroke, hover, status, and inactive icon colors; the backdrop of each material (framework, solid, frosted, Liquid Glass with its variant, linear, radial, elliptical, and angular gradients, mesh) and the theme’s glass shading; under Theme File, the name, pins, sound volume, and what the person may change | NookConfiguration.chromeTheme |
| Panel | expanded width, corner radii, content insets, a few metrics | expandedWidth, style, metrics |
| Type and Motion | four typography roles, the Settings and banner springs | typography, motion |
| Top Bar | top bar and Settings switches, width, leading title and icon, notch clearance, labels and the Settings group titles, the lock, gear, separator, and back symbols | topBar, topBar.symbols, labels |
| Companions | the size, look, and entrance every companion shares; add, duplicate, remove, and reorder companions; the selected one’s items, placement, style, and details | companionSize, companionStyle, companionPresence, addCompanion(...) |
| Effects | rim style, scroll edge fade edges and length, the chrome’s shadow | rimGlow, scrollEdgeFade, the theme’s shadow.chrome |
| Behavior | hover keeps visible, hover haptics, glass shading | chromeBehavior.hoverBehavior, chromeBehavior.glassShading |
| Activities | starts, alerts, and ends three sample live activities, to see the pill shared and the peek; the song’s cover is a shared element; not part of a preset | the module’s NookLiveActivities, nookSharedElement(_:style:) |
| Widgets | shows sample widgets on a grid in the nook’s home, with the columns, sizes, and card style you pick; not part of a preset | NookWidget, NookWidgetGrid |
| Tokens | any theme token by id: colors, numbers, fonts, animations, content transitions, sounds, shadows | NookConfiguration.chromeTheme tokens |
Some controls exist only to show a setting off. Preview on the Top Bar page posts
a status banner, and on the Type and Motion page switches between home and Settings.
The Effects page lights the rim and fills the nook with a list and a row of chips that
scroll. Header beside notch on the Top Bar page moves the nook’s own header into the
band beside the notch with nookNotchAccessories(leading:trailing:); with the top bar
off, it shows what clearing the notch
does. The theme colors start as Live - the palette the chrome resolves from the
user’s preferences - until you pick a color.
The Theme File card on the Theme page holds what a theme says beyond its look. Its pins replace the person’s palette, material, or backdrop strength while the theme is on, so a pinned value wins over the Appearance page, as it would in any host.
The Tokens page
Section titled “The Tokens page”Every value a chrome theme can set has a stable dotted id, such
as banner.cornerRadius or motion.content.enter. The Tokens page, under
Advanced, lists them all, grouped by the first part of the id, and the list comes from
the framework itself (NookTokenDescriptor.all), so a token the framework adds shows
up with no change to the playground.
- Search filters by id; Changed only shows the tokens you have overridden.
- Each row shows what the token resolves to now - with the theme’s knobs applied and any reference followed - and a dot and a reset arrow when it is overridden.
- Opening a row shows an editor for its kind: a color as one color, a pair for dark and light chrome, the accent at an opacity, or another color token; a number as a value or another number token times a factor; a font’s role, size, weight, design, width, family, and digits; a spring, a named spring, a timing curve, a Bezier curve, or another animation; a transition’s opacity, blur, scale, anchor, offset, and curve; a system, bundled, or file sound and its volume, with a button to hear it; and a shadow’s color, radius, and offset.
An override is stored as a token override in the theme, so it reaches the nook, the
Swift export, the preset, and the theme file exactly as the Theme page’s values do.
A color one of the Theme page’s roles sets is the same value on both pages. Where a
hand-built page sets the same thing - the Panel page’s metrics and shape, or the
fonts and springs on Type and Motion - that page’s value wins while it is changed
there, as NookConfiguration documents for metrics, typography, motion, and
style, and the token’s row says so.
Composing companions
Section titled “Composing companions”A companion holds a list of items, each one of:
- a button - an SF Symbol, a name, a glyph color, a fill (none, subtle, a color, or the nook’s material) with an optional fade, a size, and an action (post a status, toggle the rim glow, keep open, Settings, or collapse);
- a label - an SF Symbol and some text;
- the nook’s own lock or gear.
A group of controls is one companion with several items, laid out in a row or a
column; a control that stands apart is a companion of its own. Add starts a
companion from a template - an action pill, a round button, the nook controls, a
status chip, or nothing - and its Content tab adds, reorders, and edits the items.
The Every Companion card sets the size, entrance, fade, edge, shadow, and hover
every companion shares, and each companion’s Style tab can set its own, with
Default following the card. A button sized Surface is as tall as the
companion and draws its own surface, so a companion holding only those draws none
around them and takes the .plain style.
How a change reaches the nook
Section titled “How a change reaches the nook”The playground is a NookModule whose makeConfiguration() builds a
NookConfiguration from its current settings. After each change it calls
reloadActiveConfiguration() on the coordinator, which builds the configuration
again and applies it to the running chrome. A slider drag changes the settings
many times a second, so the playground applies them once per main-actor turn,
inside withAnimation.
Two things take a different path, as they would in any app. The appearance
preferences belong to the user, so the playground writes them to AppState with
replaceAppearancePreferences(_:), as the built-in Settings screen does. Hover
behavior belongs to the whole host rather than to a module, so it goes through
replaceChromeBehavior(_:). Nothing needs a relaunch.
Doing the same in your app
Section titled “Doing the same in your app”Any module whose configuration depends on state that changes at runtime - its own
preferences, a feature flag, a live preview - can use the same seam. Build the
configuration from that state in makeConfiguration(), keep the coordinator from
onReady, and reload when the state changes:
@MainActorfinal class DashboardModule: NookModule { nonisolated static let moduleDescriptor = NookModuleDescriptor( id: "com.example.dashboard", displayName: "Dashboard" )
let descriptor = DashboardModule.moduleDescriptor private let preferences = DashboardPreferences() // your own state private weak var coordinator: AppCoordinator?
func makeConfiguration() -> NookConfiguration { var configuration = NookConfiguration() configuration.setHome { DashboardHome() } configuration.expandedWidth = preferences.isWide ? 640 : nil if preferences.showsTimer { configuration.addCompanion(id: "timer", visibility: .both, shape: .circle) { TimerButton() } } configuration.onReady = { [weak self] coordinator in self?.coordinator = coordinator } return configuration }
func setShowsTimer(_ showsTimer: Bool) { preferences.showsTimer = showsTimer withAnimation(.snappy) { coordinator?.reloadActiveConfiguration() } }}A reload applies everything a module switch applies - the home and compact
content, theme, top bar, labels, metrics, motion, typography, width, lifecycle
hooks, file-drop handler, companions, rim glow, and scroll edge fade - and leaves
Settings when the new configuration turns Settings off. It also applies style
and transitions, which the chrome otherwise reads only at launch. It does not
call onActivate() or onReady again, and it leaves the nook in whatever state it
was in.
A few things stay as they are:
- The process-wide values a single-module configuration carries -
preferenceDefaults,chromeBehavior,branding, andshowsMenuBarExtra- are read once at launch. UsereplaceChromeBehavior(_:)to change chrome behavior. NookApp.main(configuration)wraps one fixed configuration, so a reload hands the same value back. Register a module, or a configuration closure withhost.register(_:configuration:), to build a new one each time.
At the NookSurface level, Nook.style and Nook.hoverBehavior are settable, so a
host driving a Nook directly can restyle it in place too.
Exporting Swift
Section titled “Exporting Swift”The Swift export targets the single-module NookConfiguration path and sets only
the values that differ from the framework’s defaults, so an untouched playground
exports an empty configuration. Copy it from the code card, or from the Presets page:
import NookAppimport SwiftUI
var configuration = NookConfiguration()configuration.setHome { MyHomeView() } // your home view
configuration.expandedWidth = 420configuration.style = NookConfiguration.defaultStyleconfiguration.style?.bottomCornerRadius = 30
configuration.topBar.showsKeepOpenButton = falseconfiguration.topBar.showsSettingsButton = false
// How every companion looks and appears, unless it says otherwise.configuration.companionStyle = .faded
configuration.addCompanion(id: "controls", anchor: .trailing, hidesInSettings: false) { ControlsCompanion()}
NookApp.main(configuration)
// MARK: - Companion content
struct ControlsCompanion: View { var body: some View { VStack(spacing: 2) { NookKeepOpenButton() NookSettingsButton() } }}-
Each companion’s items are written out as a view of its own, named after the companion: glyph buttons with
.nookGlyph, labels, and the framework’s lock and gear. A button’s action is a comment to replace with yours, except the ones the framework runs - keep open, Settings, and collapse - which callNookChromeActions. A companion with no items is hidden in the playground, so it is left out, with a comment saying so. -
The home view appears as a placeholder -
MyHomeView, and with a header beside the notchHeaderTitleandHeaderButtons- for you to replace with yours. -
With Header beside notch on, the home view is wrapped in a small view type that applies
nookNotchAccessories(leading:trailing:), sincesetHome(_:)takes aSendableview. -
Changed appearance preferences become
preferenceDefaults, a launch seed: they set the first run, and a choice the user later makes in Settings wins. Keep Open is left out, since the playground uses it only to hold the nook open. -
A changed shape starts from
NookConfiguration.defaultStyle, so only the values you moved appear. -
A changed rim glow style comes with a reminder that the rim only shows while some content lights it with
nookRimGlow(_:). -
The theme becomes a
NookThemewith its knobs as initializer arguments, then one line per color, token override, and backdrop:var theme = NookTheme(fontDesign: .rounded, motion: .calm)theme.accent = "#FF8000"theme.tokens[NookDimensionID("banner.cornerRadius")] = 12theme.tokens[NookShadowID("shadow.chrome")] = NookShadowSpec(radius: 10, y: 4)theme.backdrops.liquidGlass = .liquidGlass(.init(tint: .accent, variant: .clear))configuration.chromeTheme = theme
In a multi-module host, the configuration lines go in a module’s
makeConfiguration(), and preferenceDefaults and chromeBehavior go on
NookHostConfiguration instead.
JSON presets
Section titled “JSON presets”A preset is the playground’s settings plus the appearance preferences they were
tuned against. The Presets page copies, pastes, saves, and opens them, and offers a
few built-in starting points; applying one can be undone. The playground writes every
key, sorted, so presets diff cleanly under version control, and writes colors as
#RRGGBB or #RRGGBBAA. A preset written by hand can be much shorter:
{ "appearance" : { "chromePalette" : "dark", "presentation" : "floating", "surfaceStyle" : "liquidGlass" }, "format" : "opennook.playground-preset", "settings" : { "companionDefaults" : { "fade" : 0.35, "hover" : "lift" }, "companions" : [ { "alignment" : "end", "id" : "status", "items" : [ { "symbol" : "sparkles", "title" : "3 new", "type" : "label" } ] }, { "gap" : 16, "id" : "leave", "items" : [ { "action" : "collapse", "fill" : "color", "fillColor" : "#FF3B30", "size" : "surface", "symbol" : "phone.down.fill", "tint" : "#FFFFFF", "title" : "Leave", "type" : "button" } ], "backdrop" : "none", "outline" : "circle" } ], "panel" : { "expandedWidth" : 440 }, "theme" : { "accent" : "#FA5C85", "fontDesign" : "rounded" } }, "version" : 1}Presets are read leniently:
- A missing key takes its default, so a hand-written preset can hold only the values it cares about, and a preset from an earlier playground still opens.
- An unknown key is ignored.
- A value of the wrong type, or a choice the playground does not know, is an
error that names the JSON path of the value - for example
settings.panel.expandedWidth should be a number. - A preset whose
versionis newer than the playground reads is refused rather than misread. - Companion ids are made unique and non-empty, and negative lengths become zero,
since
addCompaniontraps on a duplicate id. Fades are kept between 0 and 1, and a companion keeps at most twelve items. - A companion from an earlier playground names its content with
kind(actions,button,controls, orchip) rather than listingitems, and opens with the items that kind stood for. The playground writesitemsfrom then on.
Keep Open is never part of a preset, and neither are the demo switches - the lit rim, the scrolling demo, the banner message - which the playground remembers separately.
Token overrides sit under settings.theme.tokens, one member per id in the form a
theme file writes it, and theme backdrops under settings.theme.backdrops. A key the
playground added after a preset was written is left out until you change it, so
every older preset still opens and writes back with the keys it had:
"theme" : { "fontDesign" : "rounded", "tokens" : { "banner.cornerRadius" : 12, "color.label.tertiary" : { "dark" : { "white" : 0.5 }, "light" : "accent" }, "motion.statusBanner" : "{spring.snappy}", "shadow.chrome" : { "radius" : 10, "y" : 4 } }}A token id the framework does not define is skipped, and a value of the wrong kind
for its token is an error that names it, such as
settings.theme.tokens.space.md: expected a number or a token such as "{space.md}".
Theme files
Section titled “Theme files”Theme under Share on the Presets page copies the theme alone - the Theme page,
the token overrides, and the chrome’s shadow - as a framework theme file
({"format": "opennook.theme", "version": 1, ...}), which any host loads with
NookTheme(contentsOf:) or follows live with NookThemeSource.watching(fileAt:). It
lists only what differs from NookTheme.standard: semantic tokens under tokens, the
chrome’s own fields under components. The appearance preferences, companions, top
bar, and the other pages are not part of a theme, so they stay in the preset and the
Swift export.
The assistant
Section titled “The assistant”The assistant proposes changes to the same settings the pages edit, including token
overrides by id under settings.theme.tokens and theme backdrops. Its schema lists
every token id from the framework’s registry, and an id the registry does not define
is sent back to the model to correct rather than dropped. Each token it changes is a
row of its own under Tokens in the proposal, which you can switch off before
applying.
Under swift run the playground is an unbundled, unsandboxed binary, so the save
and open panels cannot enter the folders macOS protects (Desktop, Documents,
Downloads). Use another folder, or copy and paste the JSON.
Where the playground keeps its state
Section titled “Where the playground keeps its state”- The settings and demo switches live in the module’s own defaults suite,
context.defaults(opennook.module.com.opennook.example.playground), like any module’s state. - The appearance preferences live where the framework keeps them for any host.
- The window’s own choices, such as which folded rows are open and whether the code shows, live in the app’s standard defaults.
- Reset beside Everything on the Presets page returns the settings and the appearance to the defaults.
How the example is built
Section titled “How the example is built”The playground is bigger than the other examples, so it spans several files:
Examples/PlaygroundNook/Core- thePlaygroundNookCoretarget: the settings model (PlaygroundSettings), presets and their JSON coder (PlaygroundPreset,PlaygroundPresetCoder), the Swift exporter (PlaygroundSwiftExporter), and the store. The exporters are pure, andTests/PlaygroundNookTestscovers them, including a check that every default still matches the framework’s.Examples/PlaygroundNook/App- the module, the model that applies changes, the controls window, and the views shown in the nook.
See also
Section titled “See also”- Chrome customization - the configuration seams the playground drives.
- Companion surfaces and Rim glow and edge fade - the features behind the Companions and Effects pages.
- Layout and content insets - notch clearance and views beside the notch, behind the Top Bar page’s Notch card.
- Multiple modules - modules, contexts, and the switch lifecycle a reload borrows from.