Skip to content
GitHub

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 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, or save it as a JSON preset to share or open later.

Terminal window
swift run PlaygroundNook

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:

Terminal window
swift run PlaygroundNook --preset my-preset.json # open a preset at launch
swift run PlaygroundNook --expand # open the nook at launch
Page Changes Lands on
Appearance layout, palette, material, backdrop strength, accent, haptics AppState.appearancePreferences
Theme font design; accent, label, fill, stroke, and inactive icon colors NookConfiguration.theme
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 topBar, 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 rimGlow, scrollEdgeFade
Behavior hover keeps visible, hover haptics chromeBehavior.hoverBehavior

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.

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.

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.

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:

@MainActor
final 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, and showsMenuBarExtra - are read once at launch. Use replaceChromeBehavior(_:) 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 with host.register(_:configuration:), to build a new one each time.
  • A module switch keeps the style and transitions the chrome already has.

At the NookSurface level, Nook.style and Nook.hoverBehavior are settable, so a host driving a Nook directly can restyle it in place too.

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 NookApp
import SwiftUI
var configuration = NookConfiguration()
configuration.setHome { MyHomeView() } // your home view
configuration.expandedWidth = 420
configuration.style = NookConfiguration.defaultStyle
configuration.style?.bottomCornerRadius = 30
configuration.topBar.showsKeepOpenButton = false
configuration.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 call NookChromeActions. 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 notch HeaderTitle and HeaderButtons - 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:), since setHome(_:) takes a Sendable view.
  • 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(_:).

In a multi-module host, the configuration lines go in a module’s makeConfiguration(), and preferenceDefaults and chromeBehavior go on NookHostConfiguration instead.

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 version is newer than the playground reads is refused rather than misread.
  • Companion ids are made unique and non-empty, and negative lengths become zero, since addCompanion traps 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, or chip) rather than listing items, and opens with the items that kind stood for. The playground writes items from 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.

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.

  • 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.

The playground is bigger than the other examples, so it spans several files:

  • Examples/PlaygroundNook/Core - the PlaygroundNookCore target: the settings model (PlaygroundSettings), presets and their JSON coder (PlaygroundPreset, PlaygroundPresetCoder), the Swift exporter (PlaygroundSwiftExporter), and the store. The exporters are pure, and Tests/PlaygroundNookTests covers 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.