eleven / messenger Public

Native Create-a-space: the fader goes to eleven on iPhone and Mac (#475)

* core: native create-a-space — goes-to-eleven fader, claim pipeline, haptics

web parity: FaderMath pins fader.js's constants (tests mirror
home/test/fader.test.mjs); SpaceClaim claims the provisioner's setup link
natively (derive/claim/keypair/pubkey/instance-key mint/label publish);
SpaceSetup applies the conversation's server-visible effects (group name,
setup_complete, user_invites) via the admin-role device token. CreateSpaceView
is the shared SwiftUI instrument: native drag, overpull hold, snap to eleven,
dust + confetti, CoreHaptics detents/ramp/sputter on iPhone.

iOS: drawer row 'Create a new space' + full-screen instrument.
Mac: the rail's + is back — it now means exactly one thing: create.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: integration-test the native claim against a real lchat server

Env-gated (LCHAT_ITEST_ORIGIN/_ADMIN_TOKEN — XCTSkip otherwise): mints a
setup invite the way setup_invite.go does, claims it via SpaceClaim, and
asserts the space is genuinely stood up (roster, instance key unwraps,
label decrypts, group renames via the admin-role device token, second
claim rejected). Verified passing against a local build tonight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: make the create sheet's escape gate observable + refire the features check on sign-in

busy now reads a @Published creating flag (set from the snap's async task,
cleared at settle) so interactiveDismissDisabled actually re-evaluates while
the space builds — the fader phase itself mutates per-frame inside view
evaluation and must stay unpublished. The features .task keys on
(instances, session) so signing in without an instance change still
reveals the affordance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: device-feedback polish — drag fix, offstage dust, ambient haze, volume rumble

- The drag lives on the whole track now, in local coordinates (the named
  coordinate space resolved wrong under the per-frame TimelineView rebuild
  on device — drags landed cap-local and pinned the value at 1).
- Numbers get their own row (the cap sat on the 1); slimmer meter; neater
  cap (finer grooves, quiet stripe, border + shadow).
- Dust rests BELOW the screen edge: the stage opens clean, the volume kicks
  motes into view from the bottom, silence empties it again.
- Home's ambient haze ported: drifting blurred smoke puffs + rising motes,
  theme-aware, the whole time the screen is up.
- A continuous rumble tracks the volume (CHHapticAdvancedPatternPlayer,
  ~20Hz dynamic intensity), detents punching through; cuts at the snap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: explicit 60Hz driver — TimelineView(.animation) froze on device

On the phone the TimelineView never ticked: the smoke drew once and froze,
and drags registered but nothing re-rendered the cap ('the slider does not
move'). The model now owns a main-runloop Timer (.common mode) that steps
the sim OUTSIDE view evaluation — @Published transitions are safe inline,
so the async hops go away — and bumps a published tick that re-renders the
body. Every Canvas carries the tick as a value: their field is a class
(identical reference each frame), so without a changing value SwiftUI
skipped the redraw.

Also per device feedback: the naming pane is gone — success is a confetti
beat ('Your space is ready') and the space opens on its own; naming lives
in the admin console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: device-feedback round 3 — card chrome, full-bleed stage, chaos haptics, failure wind-down

- The instrument sits on a material card (home's main.card) — the card is
  what shakes; lighter motes, more smoke, darker stage dust so the two
  particle systems read as different things.
- The particle stage is full-bleed: dust now enters from below the physical
  screen edge, not the safe-area line.
- The vibration is a random mess of taps (never a steady rhythm) — sparse
  and light low on the dial, a dense clatter by 10 and through the hold.
- A failed create is the power dying: the dial falls from eleven on a slow
  exponential curve — meter, shake, dust and the haptic mess following it
  down, descending detent ticks — and the error line lands at the bottom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: measure the particle stage inside ignoresSafeArea — the dust floor was the safe-area line

The sim's floor came from an .overlay GeometryReader outside the
safe-area-ignoring container, which reports the inset size — so 'just
below the floor' sat ~70pt above the physical bottom and the resting dust
showed as a straight row of dots. The GeometryReader now lives inside the
full-bleed container: the floor is the real screen bottom and resting
motes are genuinely offstage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: File > New Space (⇧⌘N) + Sign Out under Manage Spaces, live-gated

New Space replaces the (empty) File > New group and bumps an app-level
tick; the Sidebar owns the sheet and opens on it. Sign Out already lived
in the rail menu but hid behind a session flag refreshed only at the
rail's first appear — a browser-handoff sign-in never revealed it. Menu
content evaluates at open, so the item now gates on a live Keychain
check, sitting directly under Manage Spaces on Eleven.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: error shows at wind-down start; dust launches deeper and jumps higher

The ramp is the mood, not a delay on the message — the error line is up
the moment the power starts dying. Dust rests 26pt under the edge (the
launch reads as coming from somewhere) and jump height scales harder:
~2/3 screen at 10, clearing the screen past eleven.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core+ios: review fixes + drawer polish

Review (9 confirmed findings, the create-space set):
- The WHOLE pipeline (provision -> claim -> setup_complete) now runs inside
  create, under 'Building your space', with setup_complete retried x3 —
  quitting at the confetti can't leave a space the reaper would eat, and
  both platform closures collapse into SpaceCreationFlow.run.
- CreateSpaceSheet (observes the model) carries the escape gate on both
  platforms — the unobserved interactiveDismissDisabled froze at its
  presentation-time value; sheets also nil the model on dismiss.
- The label publishes only after grantInstanceKey verifiably lands (real
  200 check — postJSON counted any response as success).
- Network failures during claim read as network failures, never 'link
  expired'.
- The assistive increment arms the hold with a 0.4s deadline (fader.js's
  key-held overpull) — a single VoiceOver flick can't create a space.
- features() re-checks when the drawer opens / app activates, so one flaky
  fetch can't hide the affordance all session.
- stopAll() actually stops the CHHapticEngine.

Drawer (Paul): no 'Servers' title, no star glyphs, no Archived section,
and 'Create a new space' is the list's last row instead of a footer item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: drawer spacing — island headroom, centered + row, breathing room over Sign out

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: create-a-space takes over the whole window; item-based presentation everywhere

The sheet read small and could present a beat before its model landed (an
empty white square until state caught up). The Mac flow is now a
full-window overlay in RootView — the haze and dust fill the entire
viewport, the card floats mid-window — triggered by app.createSpaceTick
from the rail + and File > New Space; the Sidebar keeps only the button
and the features gate. iOS switches to fullScreenCover(item:) for the
same no-race guarantee (CreateSpaceModel is Identifiable now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: rail order — the ellipsis above, + (create) below

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core: solid white card (elevated in dark) — material read grey-on-grey

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 64e9011c8709bb32724e1368e7fab27bd8ebe5ff parent 71e7d52
13 files changed, +1594 −34
  • ios/LChat/LChatApp.swift +49 −0
  • ios/LChat/Views/InstanceSidebar.swift +36 −26
  • ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift +923 −0
  • ios/LChatCore/Sources/LChatCore/Crypto.swift +11 −0
  • ios/LChatCore/Sources/LChatCore/HomeClient.swift +52 −0
  • ios/LChatCore/Sources/LChatCore/InviteCrypto.swift +5 −3
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +27 −0
  • ios/LChatCore/Sources/LChatCore/SpaceCreate.swift +226 −0
  • ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift +79 −0
  • ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift +73 −0
  • ios/LChatMac/LChatMacApp.swift +31 −0
  • ios/LChatMac/RootView.swift +36 −0
  • ios/LChatMac/Sidebar.swift +46 −5
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 253301e..35ae11b 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -255,6 +255,11 @@ struct RootView: View {
// probe (EdgeTouchReporter) that observes the touch without consuming it; a
// SwiftUI zero-distance drag would do the same but swallow the list's taps.
@State private var edgeTouch = false
+ // Create-a-space (the goes-to-eleven flow): whether home offers it (GET
+ // /api/features), and the in-flight model while the instrument is up
+ // (item-based presentation — no present-before-content race).
+ @State private var spacesOffered = false
+ @State private var createModel: CreateSpaceModel?
private let drawerWidth: CGFloat = 300
// Slide-in / settle curve. easeOut decelerates into place and never
@@ -338,6 +343,45 @@ struct RootView: View {
}
// A push landing while we're open (see PushManager.inboxPing) refreshes live.
.onChange(of: push.inboxPing) { _, _ in refreshInbox(when: true) }
+ // Whether the drawer offers "Create a new space" — home must offer it
+ // and the member must already have a space (the web's canCreateSpace
+ // gate; the server enforces the same with 403 not_eligible).
+ .task(id: "\(app.instances.isEmpty)-\(signIn.sessionExists)") {
+ guard signIn.sessionExists, !app.instances.isEmpty else { return }
+ spacesOffered = await HomeClient().features()
+ }
+ .fullScreenCover(item: $createModel) { model in
+ CreateSpaceSheet(model: model)
+ }
+ // A features() fetch that failed once must not hide the affordance for
+ // the whole session — re-check whenever the drawer opens.
+ .onChange(of: showServers) { _, open in
+ guard open, !spacesOffered, signIn.sessionExists, !app.instances.isEmpty else { return }
+ Task { spacesOffered = await HomeClient().features() }
+ }
+ }
+
+ // The goes-to-eleven create flow. The WHOLE pipeline (provision → claim →
+ // setup_complete) runs inside `create`, under "Building your space" — the
+ // instance is persisted (and vault-pushed) before success is ever shown,
+ // so quitting at the confetti can't strand a half-born space.
+ private func makeCreateModel() -> CreateSpaceModel {
+ CreateSpaceModel(
+ create: { [weak app] in
+ // Carry the name you already go by (your most recent space).
+ let screenName = await MainActor.run {
+ app?.instances.first(where: { !$0.isArchived })?.screenName
+ ?? app?.instances.first?.screenName ?? ""
+ }
+ let inst = try await SpaceCreationFlow.run(screenName: screenName)
+ await MainActor.run { app?.add(inst) }
+ return inst
+ },
+ finish: { inst, _ in
+ select(inst)
+ createModel = nil
+ },
+ cancel: { createModel = nil })
}
// The space to open when none is selected: the one last open on this
@@ -436,6 +480,11 @@ struct RootView: View {
nav.selectInstance(nil)
showServers = false
}
+ },
+ spacesOffered: spacesOffered,
+ onCreate: {
+ createModel = makeCreateModel()
+ showServers = false
}
)
.offset(x: (progress - 1) * drawerWidth)
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift
index 24851e0..9478b8b 100644
--- a/ios/LChat/Views/InstanceSidebar.swift
+++ b/ios/LChat/Views/InstanceSidebar.swift
@@ -15,29 +15,26 @@ struct ServerDrawer: View {
// Sign out = full local reset, owned by RootView (final sync push, wipe,
// fold nav/session back to first-run).
var onSignOut: () -> Void = {}
+ // "Create a new space" (the goes-to-eleven flow, owned by RootView). Shown
+ // only when home offers provisioning AND the member already has a space —
+ // mirroring the web's canCreateSpace() gate (and the server's 403).
+ var spacesOffered = false
+ var onCreate: () -> Void = {}
// "More" starts expanded if the active space is one of the collapsed ones,
// so you never lose sight of where you are.
@State private var showMore = false
- @State private var showArchived = false
@State private var confirmSignOut = false
private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } }
private var starred: [SavedInstance] { active.filter(\.isStarred) }
private var others: [SavedInstance] { active.filter { !$0.isStarred } }
- private var archived: [SavedInstance] { app.instances.filter(\.isArchived) }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
- Text("Servers")
- .font(.title2.weight(.semibold))
- .accessibilityIdentifier("server-drawer-title") // disambiguates from the toolbar button's label in UI tests
- .padding(.horizontal, 20)
- .padding(.top, 18)
- .padding(.bottom, 10)
-
// Once anything is starred, the rail shows only the stars and folds
// the rest under "More" (matching the home page); with nothing starred
- // it's the flat, drag-reorderable list.
+ // it's the flat, drag-reorderable list. No title — the list IS the
+ // drawer (Paul's call), so it starts at the top.
List {
if starred.isEmpty {
ForEach(active) { inst in serverRow(inst) }
@@ -61,23 +58,39 @@ struct ServerDrawer: View {
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
}
}
- // Archived spaces fold at the bottom — out of the way, never
- // gone (context menu: Unarchive). Synced to home like starring.
- if !archived.isEmpty {
- DisclosureGroup(isExpanded: $showArchived) {
- ForEach(archived) { inst in serverRow(inst) }
- } label: {
- Text("Archived")
- .font(.subheadline.weight(.medium))
- .foregroundStyle(.secondary)
+ // Archived spaces don't show here at all — they live in the
+ // account (unarchive on the home page); the drawer stays the
+ // active list. "Create a new space" closes the list, where a
+ // new entry would land.
+ if #available(iOS 18.0, *), signIn.sessionExists,
+ spacesOffered, !app.instances.isEmpty {
+ Button(action: onCreate) {
+ // On the server rows' grid: the + sits in the avatar
+ // column, centered with its label.
+ HStack(spacing: 12) {
+ Image(systemName: "plus.circle")
+ .font(.title2)
+ .foregroundStyle(Color.accentColor)
+ .frame(width: 36, height: 36)
+ Text("Create a new space")
+ .font(.headline)
+ Spacer()
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .contentShape(Rectangle())
}
+ .buttonStyle(.plain)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
+ .listRowInsets(EdgeInsets(top: 1, leading: 10, bottom: 1, trailing: 10))
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
+ // Headroom under the Dynamic Island — the first row shouldn't
+ // graze the status bar.
+ .contentMargins(.top, 26, for: .scrollContent)
.onAppear { showMore = others.contains { $0.id == activeID } }
Divider()
@@ -98,6 +111,7 @@ struct ServerDrawer: View {
.font(.body.weight(.medium))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
+ .padding(.top, 14) // breathing room under the divider
.padding(.bottom, 16)
.contentShape(Rectangle())
}
@@ -198,12 +212,8 @@ struct ServerDrawer: View {
}
}
Spacer()
- if inst.isStarred {
- Image(systemName: "star.fill")
- .font(.caption2)
- .foregroundStyle(.yellow)
- .accessibilityLabel("Starred")
- }
+ // No star glyph: starred spaces already read as starred by
+ // being the rail (the fold under More is the tell).
if inst.id == activeID {
Image(systemName: "checkmark")
.font(.subheadline.weight(.semibold))
diff --git a/ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift b/ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift
new file mode 100644
index 0000000..281b4ff
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift
@@ -0,0 +1,923 @@
+import SwiftUI
+#if os(iOS)
+import CoreHaptics
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+// 🤖 The native "goes to eleven" create-a-space instrument — a SwiftUI twin of
+// home's web/static/fader.js, shared by iOS (full-screen cover) and Mac (sheet).
+// Same choreography: drag the cap to 10, keep pulling past the gap to build
+// pressure for ~900ms, and the snap to 11 fires the real create; a 950ms wild
+// window overlaps the network wait, then the "Building your space" pane holds
+// (shake + dust cranked) until the provisioner settles. Success rains confetti
+// and asks for the space's name; failure resets the fader with inline copy.
+// Pure math lives in FaderMath (SpaceCreate.swift) so tests pin it to the web.
+//
+// On iOS the drag is a real gesture and the instrument is haptic: a detent tick
+// per notch (rising with the volume), a pressure ramp through the hold, a thud
+// at the snap, and irregular "sputtering" transients for as long as the space
+// is building — the phone strains along with the meter.
+
+@MainActor
+public final class CreateSpaceModel: ObservableObject, Identifiable {
+ // Identifiable so presentation is ITEM-based (sheet(item:)/cover(item:)):
+ // isPresented + an optional model raced — the sheet could present a beat
+ // before the model state landed, showing an empty white square.
+ public let id = UUID()
+ public enum Stage: Equatable {
+ case slide // the instrument (also hosts snap/build)
+ case ready(host: String) // claimed — the confetti beat, then straight in
+ }
+
+ @Published public private(set) var stage: Stage = .slide
+ @Published public var status: String? // inline error line, never a toast
+ // From the snap until the app has switched into the new space, the sheet
+ // must not be escapable (published so interactiveDismissDisabled tracks it).
+ @Published private var creating = false
+ // The render heartbeat: bumped ~60Hz by the driver timer. Any @Published
+ // change invalidates the view, so this alone re-renders everything the sim
+ // moves. (TimelineView(.animation) silently stopped ticking on device —
+ // frozen smoke, a slider that ignored drags — hence an explicit driver.)
+ @Published private var tick = 0
+
+ // The full pipeline run at the snap: provision + claim (SavedInstance ready).
+ let create: () async throws -> SavedInstance
+ // Name + open: platform persists/selects the instance and dismisses.
+ let finish: (SavedInstance, String) async -> Void
+ let cancel: () -> Void
+
+ public init(create: @escaping () async throws -> SavedInstance,
+ finish: @escaping (SavedInstance, String) async -> Void,
+ cancel: @escaping () -> Void) {
+ self.create = create
+ self.finish = finish
+ self.cancel = cancel
+ }
+
+ // ---- fader simulation (non-published: stepped by the driver, read by render)
+
+ var phase: FaderPhase = .slide
+ var value = 1.0
+ var pressure = 0.0
+ var overpull = false
+ var dragging = false
+ var lastT: TimeInterval?
+ var phaseT0: TimeInterval = 0
+ var shakeX = 0.0, shakeY = 0.0, shakeRot = 0.0
+ var dust = DustField()
+ var confetti = ConfettiField()
+ var ambient = AmbientField()
+ let haptics = CreateHaptics()
+ var stageSize: CGSize = .zero // the screen, reported by the view
+ var reduceMotion = false // env flag, reported by the view
+ var renderTick: Int { tick } // forces the Canvases to redraw by value
+ private var driver: Timer?
+ private var lastDetent = 1
+ private var winddownT0: TimeInterval? // failure ramp-down start
+ // Assistive overpull is armed per-increment with a short deadline: holding
+ // the increment (repeated calls) sustains it toward the snap, a single
+ // flick lapses harmlessly — the web's key-held semantics (fader.js clears
+ // overpull on keyup), not a one-tap space creation.
+ private var a11yOverpullUntil: TimeInterval?
+ private var settled: Result<SavedInstance, Error>?
+ private(set) var claimed: SavedInstance?
+
+ public var busy: Bool { creating }
+
+ /// Start the 60Hz sim driver. Timer fires on the main run loop (.common,
+ /// so it keeps ticking through gesture tracking), OUTSIDE view evaluation —
+ /// stepping here may touch @Published state freely.
+ func start() {
+ guard driver == nil else { return }
+ let t = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ self.step(now: Date().timeIntervalSinceReferenceDate,
+ size: self.stageSize, reduceMotion: self.reduceMotion)
+ self.tick &+= 1
+ }
+ }
+ RunLoop.main.add(t, forMode: .common)
+ driver = t
+ }
+
+ func stop() {
+ driver?.invalidate()
+ driver = nil
+ }
+
+ func dragChanged(x: CGFloat, trackWidth: CGFloat) {
+ guard phase == .slide, winddownT0 == nil, trackWidth > 0 else { return }
+ dragging = true
+ value = FaderMath.value(fraction: x / trackWidth)
+ // Arm only when the value has bottomed out at 10 AND the pointer has
+ // physically crossed into the dead gap past the run (fader.js onMove).
+ overpull = value >= 9.99 && x > trackWidth * FaderMath.runEnd + 14
+ let notch = Int(value.rounded())
+ if notch != lastDetent {
+ haptics.detent(notch)
+ lastDetent = notch
+ }
+ }
+
+ func dragEnded() {
+ dragging = false
+ overpull = false
+ a11yOverpullUntil = nil
+ }
+
+ /// The assistive increment: step the dial; at the top, arm the hold with a
+ /// deadline so only a SUSTAINED series of increments reaches eleven.
+ func a11yIncrement(now: TimeInterval = Date().timeIntervalSinceReferenceDate) {
+ guard phase == .slide, winddownT0 == nil else { return }
+ if value >= 9.99 {
+ overpull = true
+ a11yOverpullUntil = now + 0.4
+ } else {
+ value = min(10, value.rounded() + 1)
+ }
+ }
+
+ func a11yDecrement() {
+ guard phase == .slide, winddownT0 == nil else { return }
+ value = max(1, value.rounded() - 1)
+ dragEnded()
+ }
+
+ /// One simulation step, fired by the driver timer at display rate — a
+ /// plain event context, so @Published transitions are safe inline.
+ func step(now: TimeInterval, size: CGSize, reduceMotion: Bool) {
+ let dt = min(0.1, max(0, now - (lastT ?? now)))
+ lastT = now
+
+ if phase == .slide {
+ if let t0 = winddownT0 {
+ // A failed create is the power dying: the dial falls from
+ // eleven on a slow exponential curve — meter, shake, dust and
+ // the haptic mess all following it down. The error line is
+ // already up (shown the moment the wind-down began).
+ value = 1 + 9 * exp(-(now - t0) / 0.55)
+ let notch = Int(value.rounded())
+ if notch != lastDetent {
+ haptics.detent(notch)
+ lastDetent = notch
+ }
+ haptics.chaos(drive: (value - 1) / 9, now: now)
+ if value <= 1.06 {
+ value = 1
+ winddownT0 = nil
+ }
+ } else {
+ // A lapsed assistive hold disarms (the finger path clears via
+ // dragEnded instead — a live drag never carries a deadline).
+ if let until = a11yOverpullUntil, now > until {
+ overpull = false
+ a11yOverpullUntil = nil
+ }
+ if overpull && value >= 9.99 {
+ pressure = min(1, pressure + dt * 1000 / FaderMath.holdMS)
+ haptics.holdPulse(pressure: pressure, now: now)
+ } else {
+ pressure = max(0, pressure - dt * 1000 / 300)
+ }
+ // The volume in your hand: a random mess of taps — busier and
+ // harder as the slider climbs, straining through the hold —
+ // with the notch detents punching through.
+ haptics.chaos(drive: min(1, pow(max(0, value - 1) / 9, 1.4) * 0.9
+ + pressure * 0.35), now: now)
+ if pressure >= 1 { snapToEleven(now: now) }
+ }
+ }
+
+ if phase == .snap {
+ haptics.sputterTick(now: now)
+ if (now - phaseT0) * 1000 >= FaderMath.snapMS { phase = .build }
+ }
+ if phase == .build {
+ haptics.sputterTick(now: now)
+ if let settled {
+ self.settled = nil
+ switch settled {
+ case .success(let inst):
+ claimed = inst
+ phase = .done
+ haptics.success()
+ if !reduceMotion { confetti.spawn(in: size) }
+ stage = .ready(host: URL(string: inst.origin)?.host ?? inst.origin)
+ // The confetti beat, then straight into the space — no
+ // naming step (the admin console renames any time).
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 1_400_000_000)
+ self.open()
+ }
+ case .failure(let error):
+ resetToSlide()
+ // Start the wind-down at eleven with the error line up
+ // immediately — the ramp is the mood, not a delay on
+ // finding out what happened.
+ value = 10
+ lastDetent = 10
+ winddownT0 = now
+ status = (error as? LocalizedError)?.errorDescription
+ ?? "Couldn't create a space."
+ creating = false
+ }
+ }
+ }
+
+ // Loudness drives the theatre. Past the snap it's CRANKED (fader.js).
+ let level = FaderMath.level(phase: phase, value: value, pressure: pressure)
+ if reduceMotion || level <= 0 {
+ shakeX = 0; shakeY = 0; shakeRot = 0
+ } else {
+ let mult = (phase == .snap || phase == .build) ? FaderMath.cranked : 1
+ let a = FaderMath.shake * level * mult
+ shakeX = (Double.random(in: -1...1)) * a
+ shakeY = (Double.random(in: -1...1)) * a
+ shakeRot = (Double.random(in: -0.5...0.5)) * 1.6 * level * mult
+ }
+ if !reduceMotion {
+ ambient.step(dt: dt, size: size)
+ dust.step(dt: dt, level: level, size: size, fading: phase == .done)
+ confetti.step(dt: dt, size: size)
+ }
+ }
+
+ private func snapToEleven(now: TimeInterval) {
+ phase = .snap
+ phaseT0 = now
+ value = 10
+ pressure = 0
+ haptics.snap()
+ // Fire the real create NOW — the wild window overlaps the network time.
+ Task { @MainActor in
+ self.creating = true
+ self.status = nil
+ do { self.settled = .success(try await self.create()) }
+ catch { self.settled = .failure(error) }
+ }
+ }
+
+ private func resetToSlide() {
+ phase = .slide
+ value = 1
+ pressure = 0
+ overpull = false
+ lastDetent = 1
+ haptics.stopSputter()
+ }
+
+ private var opening = false
+
+ func open() {
+ guard let inst = claimed, !opening else { return }
+ opening = true
+ Task { @MainActor in
+ await finish(inst, "")
+ creating = false
+ }
+ }
+
+ /// The cap's position along the track (fraction of width): pinned past the
+ /// gap at eleven; a small pressure "give" creeps into the gap while held.
+ var capFraction: Double {
+ if phase == .snap || phase == .build || phase == .done { return 1 }
+ return ((value - 1) / 9) * FaderMath.runEnd
+ + (pressure > 0 ? pressure * (1 - FaderMath.runEnd) * 0.35 : 0)
+ }
+}
+
+/// The sheet/cover wrapper BOTH platforms present: it observes the model, so
+/// the escape gate actually re-evaluates when the create starts (a plain
+/// .interactiveDismissDisabled at an unobserving call site froze at its
+/// presentation-time value — Escape could kill the sheet mid-create).
+public struct CreateSpaceSheet: View {
+ @ObservedObject var model: CreateSpaceModel
+
+ public init(model: CreateSpaceModel) { self.model = model }
+
+ public var body: some View {
+ CreateSpaceView(model: model)
+ .interactiveDismissDisabled(model.busy)
+ }
+}
+
+public struct CreateSpaceView: View {
+ @ObservedObject var model: CreateSpaceModel
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ public init(model: CreateSpaceModel) { self.model = model }
+
+ public var body: some View {
+ // Rendering is pure: the model's driver timer steps the sim at 60Hz
+ // and bumps a published tick, which re-evaluates this body.
+ content(now: Date().timeIntervalSinceReferenceDate)
+ .onAppear {
+ model.reduceMotion = reduceMotion
+ model.haptics.prepare()
+ model.start()
+ }
+ .onChange(of: reduceMotion) { _, r in model.reduceMotion = r }
+ .onDisappear {
+ model.stop()
+ model.haptics.stopAll()
+ }
+ }
+
+ // Solid, not material: the card should sit WHITE on the hazy room in
+ // light mode (grey-on-grey read muddy), and properly elevated in dark.
+ private var cardColor: Color {
+ #if os(iOS)
+ Color(.systemBackground)
+ #else
+ Color(nsColor: .textBackgroundColor)
+ #endif
+ }
+
+ private func content(now: TimeInterval) -> some View {
+ ZStack {
+ // Home's ambient haze — drifting smoke puffs + rising motes —
+ // behind everything, theme-aware (bootscene.js's quiet sibling).
+ // Every canvas carries the tick: the field is a class (identical
+ // reference frame-to-frame), so without a changing value SwiftUI
+ // would skip the redraw and the scene freezes. The whole particle
+ // stage is FULL-BLEED (ignoresSafeArea) and the sim runs on that
+ // size, so dust genuinely enters from below the physical screen
+ // edge — not from the safe-area line above the home indicator.
+ if !reduceMotion {
+ ZStack {
+ AmbientCanvas(field: model.ambient, tick: model.renderTick)
+ DustCanvas(field: model.dust, tick: model.renderTick)
+ // Measured INSIDE the safe-area-ignoring container so the
+ // sim's floor is the physical screen bottom. (Measuring
+ // via .overlay outside reported the safe-area size — the
+ // dust's "below the edge" rest line floated ~70pt up,
+ // visible as a straight row of dots.)
+ GeometryReader { geo in
+ Color.clear
+ .onAppear { model.stageSize = geo.size }
+ .onChange(of: geo.size) { _, s in model.stageSize = s }
+ }
+ }
+ .allowsHitTesting(false)
+ .ignoresSafeArea()
+ }
+ // The instrument sits on a card, like home's main.card — the card
+ // is what shakes.
+ card(now: now)
+ .background(cardColor,
+ in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08)))
+ .shadow(color: .black.opacity(0.12), radius: 18, y: 8)
+ .padding(.horizontal, 16)
+ .offset(x: model.shakeX, y: model.shakeY)
+ .rotationEffect(.degrees(model.shakeRot))
+ if !reduceMotion {
+ ConfettiCanvas(field: model.confetti, tick: model.renderTick)
+ .allowsHitTesting(false)
+ .ignoresSafeArea()
+ }
+ }
+ // Backing out is only offered before the snap — once the create has
+ // fired the space is real, and it opens on its own moments later.
+ .overlay(alignment: .topLeading) {
+ if model.phase == .slide, model.stage == .slide {
+ Button("Cancel") { model.cancel() }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .padding(20)
+ }
+ }
+ }
+
+ @ViewBuilder private func card(now: TimeInterval) -> some View {
+ VStack(spacing: 22) {
+ switch model.stage {
+ case .slide:
+ if model.phase == .build {
+ buildingPane
+ } else {
+ VStack(spacing: 6) {
+ Text("Slide to create")
+ .font(.title2.weight(.semibold))
+ Text("Your new space will be created immediately.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ }
+ meter(now: now)
+ fader
+ if let status = model.status {
+ Text(status)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ }
+ case .ready(let host):
+ readyPane(host: host)
+ }
+ }
+ .padding(28)
+ .frame(maxWidth: 440)
+ }
+
+ private var buildingPane: some View {
+ VStack(spacing: 10) {
+ ProgressView()
+ .controlSize(.large)
+ Text("Hold on…")
+ .font(.title3.weight(.semibold))
+ Text("Building your space")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.bottom, 6)
+ }
+
+ // The confetti beat: no forms, no buttons — the space opens on its own a
+ // moment later (naming lives in the admin console whenever they want it).
+ private func readyPane(host: String) -> some View {
+ VStack(spacing: 14) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 44))
+ .foregroundStyle(.green)
+ Text("Your space is ready")
+ .font(.title2.weight(.semibold))
+ Text(host)
+ .font(.callout.monospaced())
+ .foregroundStyle(.secondary)
+ ProgressView()
+ .controlSize(.small)
+ .padding(.top, 6)
+ }
+ }
+
+ // ---- the instrument ------------------------------------------------------
+
+ /// The 11-segment LED meter: green 1–7, amber 8–9, red 10–11 (desk
+ /// vernacular); every LED lights through the snap/build, with #11 in a
+ /// 140ms "fury" flicker for the whole window.
+ private func meter(now: TimeInterval) -> some View {
+ let hot = model.phase == .snap || model.phase == .build
+ let lit = hot ? 11 : Int(model.value.rounded())
+ return HStack(spacing: 3) {
+ ForEach(1...11, id: \.self) { n in
+ let color: Color = n <= 7 ? .green : n <= 9 ? .orange : .red
+ let on = n <= lit
+ let fury = hot && n == 11 && Int(now / 0.14) % 2 == 1
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(on ? color : Color.secondary.opacity(0.12))
+ .frame(height: 7)
+ .opacity(fury ? 0.25 : 1)
+ .shadow(color: on ? color.opacity(0.55) : .clear, radius: on ? 2.5 : 0)
+ }
+ }
+ .padding(.bottom, 2)
+ }
+
+ /// A scale mark's fractional x: 1..10 across the run, 11 pinned past the gap.
+ private func markX(_ n: Int) -> Double {
+ n == 11 ? 1.0 : (Double(n - 1) / 9) * FaderMath.runEnd
+ }
+
+ private var fader: some View {
+ VStack(spacing: 8) {
+ // The instrument: slot + ticks + cap. The drag lives on the WHOLE
+ // track (local coordinates — grab the cap or land anywhere), which
+ // is also what fixed dragging: a named coordinate space under the
+ // per-frame TimelineView rebuild resolved wrong on device.
+ GeometryReader { geo in
+ let w = geo.size.width
+ let midY = geo.size.height / 2
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 3)
+ .fill(Color.secondary.opacity(0.22))
+ .frame(height: 6)
+ .overlay(RoundedRectangle(cornerRadius: 3)
+ .strokeBorder(Color.black.opacity(0.12), lineWidth: 0.5))
+ .frame(maxHeight: .infinity, alignment: .center)
+ ForEach(1...11, id: \.self) { n in
+ Rectangle()
+ .fill(Color.secondary.opacity(0.55))
+ .frame(width: 1.5, height: n == 1 || n == 11 ? 14 : 9)
+ .position(x: markX(n) * w, y: midY)
+ }
+ cap
+ .position(x: min(max(model.capFraction * w, 14), w), y: midY)
+ }
+ .contentShape(Rectangle())
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { v in model.dragChanged(x: v.location.x, trackWidth: w) }
+ .onEnded { _ in model.dragEnded() }
+ )
+ }
+ .frame(height: 48)
+ // The scale, on its own row so the cap never sits on the numbers.
+ GeometryReader { geo in
+ let hot = model.phase == .snap || model.phase == .build
+ ForEach(1...11, id: \.self) { n in
+ let arming = n == 11 && (model.overpull || hot)
+ Text("\(n)")
+ .font(.system(size: 11, weight: n == 11 ? .heavy : .semibold))
+ .foregroundStyle(arming ? Color.red
+ : (Int(model.value.rounded()) >= n ? Color.primary : Color.secondary))
+ .scaleEffect(hot && n == 11 ? 1.4 : 1)
+ .position(x: markX(n) * geo.size.width, y: 8)
+ }
+ }
+ .frame(height: 16)
+ }
+ .accessibilityElement()
+ .accessibilityLabel("Volume slider — slide to eleven to create your space")
+ .accessibilityAdjustableAction { direction in
+ // The assistive path: step to 10, then KEEP incrementing to hold
+ // toward eleven (each arm lapses in 0.4s — fader.js's key-held
+ // overpull, never a one-tap create).
+ switch direction {
+ case .increment: model.a11yIncrement()
+ case .decrement: model.a11yDecrement()
+ @unknown default: break
+ }
+ }
+ }
+
+ /// The mixing-console cap: gradient body, two grooves, a quiet accent stripe.
+ private var cap: some View {
+ ZStack {
+ RoundedRectangle(cornerRadius: 7, style: .continuous)
+ .fill(LinearGradient(colors: [Color(white: 0.62), Color(white: 0.36)],
+ startPoint: .top, endPoint: .bottom))
+ .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous)
+ .strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
+ .frame(width: 28, height: 44)
+ .shadow(color: .black.opacity(0.3), radius: 3, y: 2)
+ VStack {
+ Rectangle().fill(Color.black.opacity(0.3)).frame(width: 18, height: 1)
+ Spacer()
+ Rectangle().fill(Color.black.opacity(0.3)).frame(width: 18, height: 1)
+ }
+ .frame(width: 28, height: 24)
+ Rectangle()
+ .fill(Color.accentColor.opacity(0.9))
+ .frame(width: 2, height: 24)
+ }
+ .allowsHitTesting(false) // the track owns the gesture
+ }
+}
+
+// ---- dust + confetti (fader.js's stage-dust canvas, card-scoped) -------------
+
+struct DustParticle {
+ var x: Double, y: Double, vx: Double, vy: Double, r: Double, accent: Bool
+}
+
+/// Stage dust that lives OFFSTAGE, just below the bottom edge — the screen
+/// starts clean, and turning the volume up kicks motes into view from below
+/// (fader.js initDust, floor-shifted): gravity 0.62/frame, kick odds
+/// 0.02 + level·0.2, bigger jumps past eleven. A settled mote rests below the
+/// edge again, so silence always empties the stage.
+final class DustField {
+ var particles: [DustParticle] = []
+ var fade = 1.0
+ /// How far below the visible edge a resting mote sits — deep enough that
+ /// the launch reads as coming from somewhere, not from the edge itself.
+ static let restDepth = 26.0
+ private var seeded = false
+
+ func step(dt: TimeInterval, level: Double, size: CGSize, fading: Bool) {
+ guard size.width > 0, size.height > 0 else { return }
+ let f = dt * 60 // web steps in ~frame units
+ let floor = size.height + Self.restDepth
+ if !seeded {
+ seeded = true
+ particles = (0..<70).map { _ in
+ DustParticle(x: .random(in: 0...size.width), y: floor,
+ vx: 0, vy: 0,
+ r: .random(in: 0.8...2.2), accent: Double.random(in: 0...1) < 0.18)
+ }
+ }
+ if fading { fade = max(0, fade - dt / 0.6) }
+ let kickOdds = 0.02 + level * 0.2
+ for i in particles.indices {
+ var p = particles[i]
+ // Only the volume lifts a resting mote into view. Jump height
+ // scales hard with the dial: waist-high mid-scale, ~2/3 screen at
+ // 10, and past eleven (level 1.2) they can clear the whole thing.
+ if p.vy == 0, p.y >= floor, level > 0.02, Double.random(in: 0...1) < kickOdds * f {
+ let peak = level > 1 ? Double.random(in: 0.65...1.05) * size.height
+ : level * level * size.height * 0.85
+ p.vy = -sqrt(max(0.1, 2 * 0.62 * (peak + Self.restDepth))) * .random(in: 0.6...1)
+ p.vx = .random(in: -0.8...0.8)
+ }
+ p.vy += 0.62 * f
+ p.x += p.vx * f
+ p.y += p.vy * f
+ if p.y >= floor {
+ p.y = floor
+ // Bounce while it still has energy; otherwise settle offstage.
+ if p.vy > 1.2 { p.vy *= -0.42 } else { p.vy = 0; p.vx = 0 }
+ }
+ if p.x < 0 { p.x += size.width }
+ if p.x > size.width { p.x -= size.width }
+ particles[i] = p
+ }
+ }
+}
+
+struct DustCanvas: View {
+ let field: DustField
+ let tick: Int // changes every frame so the redraw is never skipped
+
+ var body: some View {
+ Canvas { ctx, size in
+ for p in field.particles where p.y < size.height {
+ let rect = CGRect(x: p.x - p.r, y: p.y - p.r, width: p.r * 2, height: p.r * 2)
+ // Solid and dark on purpose — the kicked stage dust must read
+ // as a different thing from the pale ambient motes behind it.
+ let color: Color = p.accent ? .accentColor : .primary
+ ctx.fill(Path(ellipseIn: rect),
+ with: .color(color.opacity(0.7 * field.fade)))
+ }
+ }
+ }
+}
+
+// ---- ambient haze (home's bootscene, the quiet sibling) ----------------------
+
+struct AmbientPuff {
+ var x: Double, y: Double, vx: Double, r: Double, phase: Double, alpha: Double
+}
+
+struct AmbientMote {
+ var x: Double, y: Double, vy: Double, sway: Double, r: Double, alpha: Double
+}
+
+/// Home's ambient background, ported small: soft smoke puffs drifting sideways
+/// and dust motes rising through them. Runs the whole time the create screen
+/// is up — the room the instrument lives in, not a volume effect.
+final class AmbientField {
+ var puffs: [AmbientPuff] = []
+ var motes: [AmbientMote] = []
+ var t = 0.0
+ private var seeded = false
+
+ func step(dt: TimeInterval, size: CGSize) {
+ guard size.width > 0, size.height > 0 else { return }
+ t += dt
+ if !seeded {
+ seeded = true
+ puffs = (0..<18).map { _ in
+ AmbientPuff(x: .random(in: -80...size.width + 80),
+ y: .random(in: size.height * 0.2...size.height * 1.05),
+ vx: .random(in: -6...6).clampedAwayFromZero(min: 2),
+ r: .random(in: 80...170),
+ phase: .random(in: 0...(2 * .pi)),
+ alpha: .random(in: 0.07...0.14))
+ }
+ motes = (0..<36).map { _ in
+ AmbientMote(x: .random(in: 0...size.width),
+ y: .random(in: 0...size.height),
+ vy: .random(in: 8...22),
+ sway: .random(in: 0...(2 * .pi)),
+ r: .random(in: 1...2.4),
+ alpha: .random(in: 0.1...0.22))
+ }
+ }
+ for i in puffs.indices {
+ puffs[i].x += puffs[i].vx * dt
+ if puffs[i].x < -160 { puffs[i].x = size.width + 160 }
+ if puffs[i].x > size.width + 160 { puffs[i].x = -160 }
+ }
+ for i in motes.indices {
+ motes[i].y -= motes[i].vy * dt
+ motes[i].x += sin(t * 0.6 + motes[i].sway) * 6 * dt
+ if motes[i].y < -4 {
+ motes[i].y = size.height + 4
+ motes[i].x = .random(in: 0...size.width)
+ }
+ }
+ }
+}
+
+struct AmbientCanvas: View {
+ let field: AmbientField
+ let tick: Int // changes every frame so the redraw is never skipped
+ @Environment(\.colorScheme) private var scheme
+
+ var body: some View {
+ // Dark room: white smoke, light motes. Light room: grey smoke, soft
+ // grey motes — bootscene.js's two-theme rule. Motes stay a smidge
+ // lighter than the stage dust so the two read as different things.
+ let smoke: Color = scheme == .dark ? .white : Color(white: 0.3)
+ let mote: Color = scheme == .dark ? Color(white: 0.85) : Color(white: 0.45)
+ ZStack {
+ Canvas { ctx, _ in
+ for p in field.puffs {
+ let bob = sin(field.t * 0.4 + p.phase) * 10
+ let rect = CGRect(x: p.x - p.r, y: p.y + bob - p.r * 0.6,
+ width: p.r * 2, height: p.r * 1.2)
+ ctx.fill(Path(ellipseIn: rect), with: .color(smoke.opacity(p.alpha)))
+ }
+ }
+ .blur(radius: 28)
+ Canvas { ctx, _ in
+ for m in field.motes {
+ let rect = CGRect(x: m.x - m.r, y: m.y - m.r, width: m.r * 2, height: m.r * 2)
+ ctx.fill(Path(ellipseIn: rect), with: .color(mote.opacity(m.alpha)))
+ }
+ }
+ }
+ .ignoresSafeArea()
+ }
+}
+
+private extension Double {
+ /// Keep a random drift speed from being visually dead-stopped.
+ func clampedAwayFromZero(min m: Double) -> Double {
+ abs(self) < m ? (self < 0 ? -m : m) : self
+ }
+}
+
+struct ConfettiPiece {
+ var x: Double, y: Double, vx: Double, vy: Double
+ var w: Double, h: Double, spin: Double, angle: Double
+ var hue: Double, delay: Double, life: Double
+}
+
+/// Success only: 75 corner-cannon pieces + 170 sky rain (fader.js spawnConfetti).
+final class ConfettiField {
+ var pieces: [ConfettiPiece] = []
+
+ func spawn(in size: CGSize) {
+ guard size.width > 0 else { return }
+ var out: [ConfettiPiece] = []
+ for i in 0..<75 { // corner cannons
+ let left = i % 2 == 0
+ out.append(ConfettiPiece(
+ x: left ? -4 : size.width + 4, y: size.height * 0.9,
+ vx: (left ? 1 : -1) * .random(in: 3...8), vy: -.random(in: 11...18),
+ w: .random(in: 5...11), h: .random(in: 3...8),
+ spin: .random(in: -0.2...0.2), angle: .random(in: 0...(2 * .pi)),
+ hue: .random(in: 0...1), delay: 0, life: 4.6))
+ }
+ for _ in 0..<170 { // sky-wide rain
+ out.append(ConfettiPiece(
+ x: .random(in: 0...size.width), y: -.random(in: 0...30),
+ vx: .random(in: -0.6...0.6), vy: .random(in: 1...3),
+ w: .random(in: 5...11), h: .random(in: 3...8),
+ spin: .random(in: -0.2...0.2), angle: .random(in: 0...(2 * .pi)),
+ hue: .random(in: 0...1), delay: .random(in: 0...1.8), life: 4.6))
+ }
+ pieces = out
+ }
+
+ func step(dt: TimeInterval, size: CGSize) {
+ guard !pieces.isEmpty else { return }
+ let f = dt * 60
+ for i in pieces.indices {
+ var p = pieces[i]
+ if p.delay > 0 { p.delay -= dt; pieces[i] = p; continue }
+ p.life -= dt
+ p.vy += 0.18 * f
+ p.x += p.vx * f
+ p.y += p.vy * f
+ p.angle += p.spin * f
+ pieces[i] = p
+ }
+ pieces.removeAll { $0.life <= 0 || $0.y > size.height + 20 }
+ }
+}
+
+struct ConfettiCanvas: View {
+ let field: ConfettiField
+ let tick: Int // changes every frame so the redraw is never skipped
+
+ var body: some View {
+ Canvas { ctx, _ in
+ for p in field.pieces where p.delay <= 0 {
+ var c = ctx
+ c.translateBy(x: p.x, y: p.y)
+ c.rotate(by: .radians(p.angle))
+ let rect = CGRect(x: -p.w / 2, y: -p.h / 2, width: p.w, height: p.h)
+ let color = Color(hue: p.hue, saturation: 0.92, brightness: 0.85)
+ c.fill(Path(rect), with: .color(color.opacity(min(1, p.life / 1.2))))
+ }
+ }
+ }
+}
+
+// ---- haptics ------------------------------------------------------------------
+
+/// The instrument's feel. iOS: CoreHaptics transients — a detent per notch
+/// (rising with the volume), a pressure ramp through the hold, a thud at the
+/// snap, and irregular "sputtering" ticks for the whole build wait. Falls back
+/// to UIImpactFeedbackGenerator when the engine is unavailable. Mac: the
+/// trackpad's alignment tick per notch (NSHapticFeedbackManager).
+public final class CreateHaptics {
+ #if os(iOS)
+ private var engine: CHHapticEngine?
+ private let impact = UIImpactFeedbackGenerator(style: .rigid)
+ private let notify = UINotificationFeedbackGenerator()
+ #endif
+ private var nextHold: TimeInterval = 0
+ private var nextSputter: TimeInterval = 0
+ private var nextChaos: TimeInterval = 0
+ private var sputtering = false
+
+ public init() {}
+
+ func prepare() {
+ #if os(iOS)
+ guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
+ engine = try? CHHapticEngine()
+ engine?.resetHandler = { [weak self] in try? self?.engine?.start() }
+ try? engine?.start()
+ impact.prepare()
+ #endif
+ }
+
+ /// One transient tap. Intensity/sharpness 0…1.
+ private func tap(intensity: Double, sharpness: Double) {
+ #if os(iOS)
+ if let engine,
+ let pattern = try? CHHapticPattern(events: [CHHapticEvent(
+ eventType: .hapticTransient,
+ parameters: [
+ CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(intensity)),
+ CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(sharpness)),
+ ], relativeTime: 0)], parameters: []),
+ let player = try? engine.makePlayer(with: pattern) {
+ try? player.start(atTime: 0)
+ } else {
+ impact.impactOccurred(intensity: intensity)
+ }
+ #endif
+ }
+
+ /// A notch crossed: a tick that firms up as the volume climbs.
+ func detent(_ n: Int) {
+ tap(intensity: 0.25 + 0.75 * Double(n) / 11, sharpness: 0.5)
+ #if os(macOS)
+ NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)
+ #endif
+ }
+
+ /// The hold: pulses every ~70ms, harder as pressure builds toward the snap.
+ func holdPulse(pressure: Double, now: TimeInterval) {
+ guard now >= nextHold else { return }
+ nextHold = now + 0.07
+ tap(intensity: 0.2 + 0.8 * pressure, sharpness: 0.7)
+ }
+
+ /// The volume itself, in the hand: a RANDOM MESS of taps — never a steady
+ /// rhythm. `drive` (0…1) sets how busy and how hard: sparse light ticks
+ /// low on the scale, a dense violent clatter by 10 and through the hold.
+ func chaos(drive: Double, now: TimeInterval) {
+ guard drive > 0.03, now >= nextChaos else { return }
+ let gapLo = max(0.028, 0.16 - 0.12 * drive)
+ let gapHi = max(gapLo + 0.02, 0.34 - 0.26 * drive)
+ nextChaos = now + .random(in: gapLo...gapHi)
+ tap(intensity: min(1, drive * .random(in: 0.35...1.25)),
+ sharpness: .random(in: 0.15...1))
+ }
+
+ /// Eleven. A full-strength thud; the sputter starts on the next tick.
+ func snap() {
+ tap(intensity: 1, sharpness: 1)
+ sputtering = true
+ #if os(macOS)
+ NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .now)
+ #endif
+ }
+
+ /// The build wait: irregular "spitty" transients — random strength and
+ /// spacing, like a machine straining — until success/failure stops it.
+ func sputterTick(now: TimeInterval) {
+ guard sputtering, now >= nextSputter else { return }
+ nextSputter = now + .random(in: 0.05...0.19)
+ tap(intensity: .random(in: 0.25...1), sharpness: .random(in: 0.3...1))
+ }
+
+ func stopSputter() { sputtering = false }
+
+ func stopAll() {
+ stopSputter()
+ #if os(iOS)
+ // The engine keeps a hardware session alive until stopped — dismissal
+ // must actually shut it down, not just quiet the pattern flags.
+ engine?.stop()
+ engine = nil
+ #endif
+ }
+
+ func success() {
+ stopSputter()
+ #if os(iOS)
+ notify.notificationOccurred(.success)
+ #endif
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Crypto.swift b/ios/LChatCore/Sources/LChatCore/Crypto.swift
index a296b3e..1484fcc 100644
--- a/ios/LChatCore/Sources/LChatCore/Crypto.swift
+++ b/ios/LChatCore/Sources/LChatCore/Crypto.swift
@@ -44,6 +44,17 @@ public enum ECIES {
SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
}
+ /// A fresh user identity keypair, matching web/static/crypto.js
+ /// generateUserKeypair: pub = base64url raw (uncompressed x963) point,
+ /// priv = base64url PKCS#8 DER — the exact SavedInstance field formats,
+ /// so a natively-claimed identity round-trips through every existing
+ /// wrap/unwrap path (and through the web, via the home vault).
+ public static func generateUserKeypair() -> (pub: String, priv: String) {
+ let priv = P256.KeyAgreement.PrivateKey()
+ return (priv.publicKey.x963Representation.base64urlEncodedString(),
+ priv.derRepresentation.base64urlEncodedString())
+ }
+
/// Wraps a raw AES key (32 bytes) to a recipient's user public key (base64url
/// raw/x963), producing the base64url blob `ephPub(65) || iv(12) || ct||tag` —
/// byte-compatible with web/static/crypto.js `wrapThreadKey`. Used when starting
diff --git a/ios/LChatCore/Sources/LChatCore/HomeClient.swift b/ios/LChatCore/Sources/LChatCore/HomeClient.swift
index bed79d7..dd10f50 100644
--- a/ios/LChatCore/Sources/LChatCore/HomeClient.swift
+++ b/ios/LChatCore/Sources/LChatCore/HomeClient.swift
@@ -93,6 +93,58 @@ public struct HomeClient {
return try JSONDecoder().decode(R.self, from: data).version.value
}
+ // ---- space creation (the cloud provisioner, via home) -------------------
+
+ /// GET /api/features — whether this home server offers space creation
+ /// (a paired cloud provisioner). False on error/older servers, so callers
+ /// simply don't show the affordance (the web hides its + the same way).
+ public func features() async -> Bool {
+ struct R: Decodable { let spaces: Bool? }
+ guard let r: R = try? await getJSON("GET", "/api/features") else { return false }
+ return r.spaces ?? false
+ }
+
+ public struct SpaceCreation: Decodable {
+ public let host: String
+ public let url: String
+ public let setup_link: String
+ }
+
+ /// A create-space failure carrying the server's user-facing copy verbatim
+ /// (quota, capacity, eligibility — home writes these for members).
+ public struct SpaceCreateError: LocalizedError {
+ public let message: String
+ public var errorDescription: String? { message }
+ public init(message: String) { self.message = message }
+ }
+
+ /// POST /api/spaces — create a fresh instance. The call blocks while the
+ /// box starts the instance, health-checks it, and confirms the PUBLIC path
+ /// answers (~45s worst case), so the returned setup link works on first
+ /// use. Auth is the home link token; create is additive, no ceremony proof.
+ public func createSpace(token: String) async throws -> SpaceCreation {
+ var req = URLRequest(url: base.appending(path: "/api/spaces"))
+ req.httpMethod = "POST"
+ req.timeoutInterval = 75 // home's own downstream budget is 45s
+ req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ let data: Data, resp: URLResponse
+ do {
+ (data, resp) = try await URLSession.shared.data(for: req)
+ } catch {
+ throw SpaceCreateError(message: "Couldn't reach Eleven — check your connection and try again.")
+ }
+ guard let http = resp as? HTTPURLResponse else {
+ throw SpaceCreateError(message: "Couldn't reach Eleven — check your connection and try again.")
+ }
+ guard http.statusCode == 201 else {
+ struct E: Decodable { let error: String?; let message: String? }
+ let e = try? JSONDecoder().decode(E.self, from: data)
+ throw SpaceCreateError(message: e?.message ?? e?.error
+ ?? "Couldn't create a space (HTTP \(http.statusCode)).")
+ }
+ return try JSONDecoder().decode(SpaceCreation.self, from: data)
+ }
+
// ---- plumbing -----------------------------------------------------------
private func getJSON<T: Decodable>(_ method: String, _ path: String) async throws -> T {
diff --git a/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift b/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift
index c0763f5..01b712f 100644
--- a/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift
+++ b/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift
@@ -8,9 +8,10 @@ import CryptoKit
// docs/invite-redesign.md.
public enum InviteCrypto {
public struct Derived {
- public let id: String // b64url HKDF("lchat-invite-id")
- public let wrapKey: GroupKey // AES-GCM from HKDF("lchat-invite-wrap")
- public let claimHash: String // lowercase hex SHA-256 of the claim secret
+ public let id: String // b64url HKDF("lchat-invite-id")
+ public let wrapKey: GroupKey // AES-GCM from HKDF("lchat-invite-wrap")
+ public let claimSecret: String // b64url HKDF("lchat-invite-claim") — sent raw to CLAIM
+ public let claimHash: String // lowercase hex SHA-256 of the claim secret (stored on MINT)
}
/// A fresh 128-bit invite secret (crypto.js randomSecret).
@@ -33,6 +34,7 @@ public enum InviteCrypto {
.map { String(format: "%02x", $0) }.joined()
return Derived(id: bits("lchat-invite-id").base64urlEncodedString(),
wrapKey: try GroupKey(rawKey: bits("lchat-invite-wrap")),
+ claimSecret: claimSecret,
claimHash: claimHash)
}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 90d0668..c77819b 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -40,6 +40,33 @@ public struct LChatAPI {
return (wrapped?.isEmpty ?? true) ? nil : wrapped
}
+ /// Full instance-key state: our wrapped copy (if granted) plus who already
+ /// holds one — the web's setupInstanceKey inputs. `have` empty and no
+ /// wrapped copy means nobody has minted the key yet (a brand-new space).
+ public func instanceKeyStatus() async -> (wrapped: String?, have: [String])? {
+ struct Resp: Codable { let wrapped: String?; let have: [String]? }
+ guard let r = try? await getJSON("/api/instance-key", Resp.self) else { return nil }
+ return ((r.wrapped?.isEmpty ?? true) ? nil : r.wrapped, r.have ?? [])
+ }
+
+ /// Grant the instance key to members: [{user_id, wrapped}] where each
+ /// wrapped blob is the key ECIES-wrapped to that member's pubkey — the
+ /// server stores blobs it can't open (mirrors the web's grantInstanceKeyTo).
+ /// True only on a REAL 200 — callers publish labels under this key, so a
+ /// swallowed 4xx/5xx must not read as success.
+ @discardableResult
+ public func grantInstanceKey(_ grants: [[String: String]]) async -> Bool {
+ guard let url = URL(string: "api/instance-key", relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject:
+ ["token": token, "grants": grants]) else { return false }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ guard let (_, resp) = try? await session.data(for: req) else { return false }
+ return (resp as? HTTPURLResponse)?.statusCode == 200
+ }
+
/// Messages in a thread after a cursor id — used to count unread (the feed
/// itself is rendered by the webview).
public func messages(thread: String, after: Int64) async -> [ServerMessage] {
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
new file mode 100644
index 0000000..50322a7
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
@@ -0,0 +1,226 @@
+import Foundation
+
+// 🤖 Native "Create a new space": the pure fader math (a port of home's
+// web/static/fader.js — home/test/fader.test.mjs pins the same values on the
+// web side, FaderMathTests pins these) and the claim pipeline that turns the
+// provisioner's setup link into a working SavedInstance without any webview.
+//
+// The claim mirrors web/static/join.js's setup-invite branch exactly: derive
+// {id, wrapKey, claimSecret} from the fragment secret (the server never sees
+// T), claim for a device token, generate a fresh identity keypair, register
+// the pubkey — then, because we're the space's first member, bootstrap the
+// instance key (mirroring shell.js setupInstanceKey's first-member branch)
+// and publish our encrypted label under it. The server stores only blobs.
+
+// ---- fader math (see fader.js — keep the constants in lockstep) ------------
+
+public enum FaderPhase: Equatable {
+ case idle, slide, snap, build, done
+}
+
+public enum FaderMath {
+ /// 1..10 live in the first 86% of the slot; 11 sits past the gap.
+ public static let runEnd = 0.86
+ /// Continued pull at 10 before the snap to 11 (ms).
+ public static let holdMS = 900.0
+ /// The wild window between the snap and the building pane (ms).
+ public static let snapMS = 950.0
+ /// Base shake amplitude (points) at full level; multiplier at eleven.
+ public static let shake = 1.7
+ public static let cranked = 2.3
+
+ /// Position → value: linear 1..10 across the run, clamped — eleven is
+ /// never reachable by position, only by the hold.
+ public static func value(fraction: Double) -> Double {
+ 1 + min(1, max(0, fraction / runEnd)) * 9
+ }
+
+ /// The scene's "loudness" (shake/dust/LED drive): silent below 5, ramping
+ /// to 5/6 at 10, pinned PAST the scale (1.2) for the whole snap+build, and
+ /// cut dead at done (silence before the confetti).
+ public static func level(phase: FaderPhase, value: Double, pressure: Double) -> Double {
+ if phase == .snap || phase == .build { return 1.2 }
+ if phase == .done { return 0 }
+ return max(0, (value - 5) / 6) + pressure * 0.25
+ }
+}
+
+// ---- the claim pipeline -----------------------------------------------------
+
+public struct SpaceClaimError: LocalizedError {
+ public let message: String
+ public var errorDescription: String? { message }
+ init(_ m: String) { message = m }
+}
+
+/// The whole native pipeline, run under "Building your space" and finished
+/// BEFORE success is declared: provision via home, claim the setup link, and
+/// apply the minimal setup — so quitting at the confetti can't strand a
+/// half-born space, and setup_complete (the reaper's not-abandoned signal)
+/// never rides on a fire-and-forget call after the fact.
+public enum SpaceCreationFlow {
+ public static func run(screenName: String) async throws -> SavedInstance {
+ guard let session = HomeSessionStore.load() else {
+ throw SpaceClaimError("Sign in first — your new space is saved into your account.")
+ }
+ let creation = try await HomeClient().createSpace(token: session.token)
+ let inst = try await SpaceClaim.claim(setupLink: creation.setup_link,
+ screenName: screenName)
+ await SpaceSetup.finish(origin: inst.origin, token: inst.deviceToken, name: "")
+ return inst
+ }
+}
+
+public enum SpaceClaim {
+ static let cantReach = "Couldn't reach your new space — check your connection and try again."
+
+ /// Claim a `/join/setup#<T>` link natively and stand the space up for this
+ /// device: device token, fresh identity keypair, registered pubkey, minted
+ /// instance key, published label. Returns a ready-to-add SavedInstance
+ /// (myUserId resolved when the roster already lists us; else it resolves
+ /// on first thread-list load, like any instance).
+ public static func claim(setupLink: String, screenName: String) async throws -> SavedInstance {
+ guard let (origin, joinURL) = parseJoinLink(setupLink),
+ let base = URL(string: origin),
+ let t = inviteSecret(fromFragment: joinURL.fragment ?? ""),
+ let derived = try? InviteCrypto.derive(t) else {
+ throw SpaceClaimError("That setup link doesn't look right.")
+ }
+
+ // The invite must still be live (single-use; the server only ever sees
+ // the derived id, never T). A network failure is a network failure —
+ // never misreported as "expired" (the retry-able and the dead-end are
+ // different problems).
+ struct InviteInfo: Decodable { let ok: Bool?; let grants_admin: Bool? }
+ let info: InviteInfo
+ do {
+ info = try await getJSON(base.appending(path: "/api/key-invite/\(derived.id)"))
+ } catch {
+ throw SpaceClaimError(cantReach)
+ }
+ guard info.ok == true else {
+ throw SpaceClaimError("This setup link has expired or was already used.")
+ }
+
+ // Claim: proof-of-possession is the derived claim secret; a setup
+ // invite ships no wrapped key, so `wrapped` rides empty.
+ struct ClaimResp: Decodable { let ok: Bool?; let token: String? }
+ let claim: ClaimResp
+ do {
+ claim = try await postJSON(
+ base.appending(path: "/api/key-invite/\(derived.id)/claim"),
+ body: ["wrapped": "", "claim_secret": derived.claimSecret])
+ } catch {
+ throw SpaceClaimError(cantReach)
+ }
+ guard claim.ok == true, let token = claim.token, !token.isEmpty else {
+ throw SpaceClaimError("Couldn't claim your space — the setup link may already be used.")
+ }
+
+ // Fresh identity: the private key exists only here (Keychain-bound via
+ // SavedInstance); the server learns just the public half.
+ let keypair = ECIES.generateUserKeypair()
+ let api = LChatAPI(baseURL: base, token: token)
+ await api.ensurePubkey(keypair.pub)
+
+ var inst = SavedInstance(origin: origin, deviceToken: token,
+ screenName: screenName,
+ userPub: keypair.pub, userPriv: keypair.priv)
+ inst.myUserId = await api.roster()
+ .first(where: { $0.public_key == keypair.pub })?.user_id ?? ""
+
+ // First member of a brand-new space: bootstrap the instance key
+ // (shell.js setupInstanceKey's mint branch) so labels, profile photos
+ // and native invites work without the space ever being opened on web.
+ // Best-effort — a web visit heals a missing key via the nudge path —
+ // but the label publishes ONLY once the grant verifiably landed:
+ // a label under a key the server never kept decrypts for no one.
+ if !inst.myUserId.isEmpty,
+ let status = await api.instanceKeyStatus(),
+ status.wrapped == nil, status.have.isEmpty {
+ let raw = ECIES.randomAESKey()
+ if let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: keypair.pub),
+ await api.grantInstanceKey([["user_id": inst.myUserId, "wrapped": wrapped]]),
+ !screenName.isEmpty, let ik = try? GroupKey(rawKey: raw),
+ let blob = try? ik.encryptJSON(screenName) {
+ await api.postLabel(labelBlob: blob)
+ }
+ }
+ return inst
+ }
+
+ /// The invite secret from a `#fragment`: strip the "e1." app-hint prefix
+ /// and any "~<suggested name>" tail (invite.js parseInviteFragment).
+ static func inviteSecret(fromFragment fragment: String) -> String? {
+ var f = fragment
+ if f.hasPrefix("e1.") { f = String(f.dropFirst(3)) }
+ if let tilde = f.firstIndex(of: "~") { f = String(f[..<tilde]) }
+ return f.isEmpty ? nil : f
+ }
+
+ private static func getJSON<T: Decodable>(_ url: URL) async throws -> T {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ return try JSONDecoder().decode(T.self, from: data)
+ }
+
+ private static func postJSON<T: Decodable>(_ url: URL, body: [String: Any]) async throws -> T {
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+ let (data, _) = try await URLSession.shared.data(for: req)
+ return try JSONDecoder().decode(T.self, from: data)
+ }
+}
+
+// ---- minimal native setup (the conversation's side effects, sans script) ----
+
+public enum SpaceSetup {
+ /// Name the space and mark setup done — the two server-visible effects of
+ /// the web setup conversation a native creator needs (the rest — photo,
+ /// contact details — live in the Admin app / admin console). The claimed
+ /// device token authorises both: its user holds the admin role
+ /// (requireAdmin's second door), so no admin token ever touches the app.
+ /// Member invites default ON, matching the seeded LCHAT_SKIP_SETUP posture.
+ /// The name is plaintext branding by design (it must render where no key
+ /// exists yet); the member's own screen name is NOT sent — that stays E2E.
+ @discardableResult
+ public static func finish(origin: String, token: String, name: String) async -> Bool {
+ guard let base = URL(string: origin) else { return false }
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ var ok = true
+ if !trimmed.isEmpty {
+ // setGroup clears any field it isn't sent; a fresh space has an
+ // empty trio, so sending empties alongside the name is faithful.
+ ok = await retrying { await post(base, "/api/admin/group", token: token,
+ json: ["name": String(trimmed.prefix(60)),
+ "admin_name": "", "admin_email": ""]) } && ok
+ }
+ // setup_complete is the reaper's not-abandoned signal — worth retries.
+ ok = await retrying { await post(base, "/api/admin/settings", token: token,
+ json: ["user_invites": true, "setup_complete": true]) } && ok
+ return ok
+ }
+
+ /// Up to three attempts with a short breath between — the call runs right
+ /// after the instance's first health check, when a hiccup is most likely.
+ private static func retrying(_ op: () async -> Bool) async -> Bool {
+ for attempt in 0..<3 {
+ if await op() { return true }
+ if attempt < 2 { try? await Task.sleep(nanoseconds: 400_000_000) }
+ }
+ return false
+ }
+
+ private static func post(_ base: URL, _ path: String, token: String,
+ json: [String: Any]) async -> Bool {
+ var req = URLRequest(url: base.appending(path: path))
+ req.httpMethod = "POST"
+ req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try? JSONSerialization.data(withJSONObject: json)
+ guard let (_, resp) = try? await URLSession.shared.data(for: req),
+ let http = resp as? HTTPURLResponse else { return false }
+ return (200..<300).contains(http.statusCode)
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift
new file mode 100644
index 0000000..30ad15d
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift
@@ -0,0 +1,79 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 End-to-end proof of the native claim pipeline against a REAL lchat server
+// (wire formats are exactly where a re-implementation lies). Skipped unless the
+// env points at one — run locally with:
+//
+// go build -o /tmp/lchat-itest . && LCHAT_ADMIN_TOKEN=itest-token \
+// LCHAT_DB=/tmp/lchat-itest.db ADDR=127.0.0.1:8091 /tmp/lchat-itest &
+// LCHAT_ITEST_ORIGIN=http://127.0.0.1:8091 LCHAT_ITEST_ADMIN_TOKEN=itest-token \
+// swift test --filter SpaceClaimIntegrationTests
+//
+// The test mints its own setup invite (the exact shape setup_invite.go mints),
+// claims it natively, and asserts the space is genuinely stood up: token valid,
+// user in the roster, instance key minted + unwrappable, label decryptable,
+// group named, setup marked complete.
+final class SpaceClaimIntegrationTests: XCTestCase {
+ func testClaimSetupInviteAgainstRealServer() async throws {
+ let env = ProcessInfo.processInfo.environment
+ guard let origin = env["LCHAT_ITEST_ORIGIN"], let admin = env["LCHAT_ITEST_ADMIN_TOKEN"] else {
+ throw XCTSkip("set LCHAT_ITEST_ORIGIN + LCHAT_ITEST_ADMIN_TOKEN to run")
+ }
+ let base = URL(string: origin)!
+
+ // Mint a setup invite the way setup_invite.go does (admin-token API).
+ let t = InviteCrypto.randomSecret()
+ let derived = try InviteCrypto.derive(t)
+ var mint = URLRequest(url: base.appending(path: "/api/admin/key-invites"))
+ mint.httpMethod = "POST"
+ mint.setValue("Bearer " + admin, forHTTPHeaderField: "Authorization")
+ mint.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ mint.httpBody = try JSONSerialization.data(withJSONObject: [
+ "id": derived.id, "claim_hash": derived.claimHash, "grants_admin": true,
+ "expires": Int(Date().addingTimeInterval(3600).timeIntervalSince1970),
+ ])
+ let (_, mintResp) = try await URLSession.shared.data(for: mint)
+ XCTAssertEqual((mintResp as? HTTPURLResponse)?.statusCode, 200, "invite mint failed")
+
+ // Claim it natively — the full pipeline.
+ let inst = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t,
+ screenName: "Overnight Tester")
+ XCTAssertEqual(inst.origin, origin)
+ XCTAssertFalse(inst.deviceToken.isEmpty)
+ XCTAssertFalse(inst.myUserId.isEmpty, "roster should already know us")
+
+ // The token works and the roster carries our (encrypted) label.
+ let api = LChatAPI(baseURL: base, token: inst.deviceToken)
+ let me = await api.roster().first { $0.user_id == inst.myUserId }
+ XCTAssertNotNil(me)
+ XCTAssertEqual(me?.public_key, inst.userPub)
+
+ // First-member instance-key mint: our wrapped copy exists and opens
+ // with our private key; the published label decrypts under it.
+ let status = await api.instanceKeyStatus()
+ let wrapped = try XCTUnwrap(status?.wrapped, "instance key should be minted + granted to us")
+ let raw = try ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: inst.userPriv)
+ if let labelBlob = me?.label_blob {
+ let label = try GroupKey(rawKey: raw).decryptString(blob: labelBlob)
+ XCTAssertEqual(label, "Overnight Tester")
+ } else {
+ XCTFail("label_blob should be published at claim time")
+ }
+
+ // The minimal native setup: name + setup_complete via the admin-ROLE
+ // device token (never the admin token).
+ let ok = await SpaceSetup.finish(origin: origin, token: inst.deviceToken,
+ name: "Goes To Eleven")
+ XCTAssertTrue(ok)
+ let groupName = await api.groupName()
+ XCTAssertEqual(groupName, "Goes To Eleven")
+
+ // A second claim of the same (single-use) invite must fail.
+ do {
+ _ = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t,
+ screenName: "Second Claimer")
+ XCTFail("single-use invite claimed twice")
+ } catch {}
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift
new file mode 100644
index 0000000..655dcb8
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift
@@ -0,0 +1,73 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Pins the native fader math to home's web fader (home/test/fader.test.mjs
+// asserts the same values on the JS side) and the claim-side crypto helpers.
+final class SpaceCreateTests: XCTestCase {
+ // ---- fader math (twin of fader.test.mjs — keep the cases in lockstep) ----
+
+ func testFaderValueMapsTheSlotRunTo1Through10AndClampsBeyondIt() {
+ XCTAssertEqual(FaderMath.value(fraction: 0), 1)
+ XCTAssertEqual(FaderMath.value(fraction: 0.86), 10, "the end of the run (runEnd) is 10")
+ XCTAssertEqual(FaderMath.value(fraction: 1), 10,
+ "past the run still clamps to 10 — eleven is not reachable by position")
+ XCTAssertEqual(FaderMath.value(fraction: -0.5), 1, "before the slot clamps to 1")
+ let mid = FaderMath.value(fraction: 0.43)
+ XCTAssertTrue(mid > 5 && mid < 6.6, "mid-run lands mid-scale")
+ }
+
+ func testFaderLevelIsSilentBelow5HotBy10AndPressureAddsStrain() {
+ XCTAssertEqual(FaderMath.level(phase: .slide, value: 1, pressure: 0), 0)
+ XCTAssertEqual(FaderMath.level(phase: .slide, value: 5, pressure: 0), 0)
+ XCTAssertEqual(FaderMath.level(phase: .slide, value: 10, pressure: 0), 5.0 / 6, accuracy: 1e-9)
+ XCTAssertGreaterThan(FaderMath.level(phase: .slide, value: 10, pressure: 0.8),
+ FaderMath.level(phase: .slide, value: 10, pressure: 0),
+ "the hold adds strain")
+ }
+
+ func testFaderLevelHoldsPast1AtTheSnapAndThroughTheWholeBuild() {
+ XCTAssertEqual(FaderMath.level(phase: .snap, value: 10, pressure: 0), 1.2)
+ XCTAssertEqual(FaderMath.level(phase: .build, value: 10, pressure: 0), 1.2,
+ "the volume stays AT eleven while building")
+ }
+
+ func testFaderLevelCutsToZeroAtDone() {
+ XCTAssertEqual(FaderMath.level(phase: .done, value: 10, pressure: 0), 0,
+ "silence before the confetti")
+ }
+
+ // ---- claim crypto ---------------------------------------------------------
+
+ func testDerivedClaimSecretHashesToTheClaimHash() throws {
+ // The raw claim secret (sent on CLAIM) must be the exact preimage of the
+ // claim hash (stored on MINT) — the two sides of proof-of-possession.
+ let derived = try InviteCrypto.derive(InviteCrypto.randomSecret())
+ let hash = SHA256Hex(Data(derived.claimSecret.utf8))
+ XCTAssertEqual(hash, derived.claimHash)
+ XCTAssertFalse(derived.claimSecret.isEmpty)
+ }
+
+ func testGeneratedUserKeypairRoundTripsThroughECIES() throws {
+ // A fresh native identity must interoperate with the existing wrap paths:
+ // wrap a key to the new pubkey, unwrap it with the new private key.
+ let kp = ECIES.generateUserKeypair()
+ let raw = ECIES.randomAESKey()
+ let wrapped = try ECIES.wrap(rawKey: raw, recipientPubB64: kp.pub)
+ XCTAssertEqual(try ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: kp.priv), raw)
+ }
+
+ func testInviteSecretParsingStripsPrefixAndSuggestedName() {
+ XCTAssertEqual(SpaceClaim.inviteSecret(fromFragment: "e1.abc123"), "abc123")
+ XCTAssertEqual(SpaceClaim.inviteSecret(fromFragment: "e1.abc123~Paul"), "abc123")
+ XCTAssertEqual(SpaceClaim.inviteSecret(fromFragment: "abc123"), "abc123",
+ "older links carry no e1. hint")
+ XCTAssertNil(SpaceClaim.inviteSecret(fromFragment: ""))
+ XCTAssertNil(SpaceClaim.inviteSecret(fromFragment: "e1."))
+ }
+}
+
+private func SHA256Hex(_ data: Data) -> String {
+ CryptoKit.SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+}
+
+import CryptoKit
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index cae76f2..7678838 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -64,6 +64,14 @@ struct LChatMacApp: App {
.windowStyle(.hiddenTitleBar)
.defaultSize(width: 1160, height: 820)
.commands {
+ CommandGroup(replacing: .newItem) {
+ // File → New Space: the goes-to-eleven create flow (the rail's
+ // "+"). ⇧⌘N, the new-container idiom. The Sidebar owns the
+ // sheet; it opens on the tick.
+ Button("New Space") { app.requestCreateSpace() }
+ .keyboardShortcut("n", modifiers: [.command, .shift])
+ .disabled(app.instances.isEmpty)
+ }
CommandGroup(after: .newItem) {
// A MENU shortcut, deliberately: a view-level hidden button
// lost ⌘F whenever a greedy first responder (a mini-app's
@@ -214,6 +222,11 @@ final class MacAppState: ObservableObject {
// counter moves ±1), and any change is one step — the active NativeChatView
// observes it.
@Published var cycleChatTick = 0
+ // File → New Space (and anything else app-level) asks for the create flow
+ // by bumping this; the Sidebar owns the sheet and opens on the tick.
+ @Published var createSpaceTick = 0
+
+ func requestCreateSpace() { createSpaceTick += 1 }
// A "open this thread AND land on this message" request (search hits):
// the matching feed consumes it once its history contains the target.
@Published var nativeJump: NativeJumpTarget?
@@ -424,6 +437,24 @@ final class MacAppState: ObservableObject {
return added
}
+ /// A single natively-created identity (the create-a-space claim): upsert
+ /// the SavedInstance, ensure a rail entry, select it, and push the vault —
+ /// applyVault's one-server sibling for a space born on this Mac.
+ func addInstance(_ inst: SavedInstance) {
+ if let i = instances.firstIndex(where: { $0.origin == inst.origin }) {
+ instances[i] = inst
+ } else {
+ instances.append(inst)
+ }
+ InstancesStore.save(instances)
+ if !chats.contains(where: { $0.origin?.absoluteString == inst.origin }) {
+ chats.append(SavedChat(url: inst.origin, name: inst.screenName))
+ MacChatsStore.save(chats)
+ }
+ selection = chats.first(where: { $0.origin?.absoluteString == inst.origin })?.id
+ syncHome()
+ }
+
// ---- starring (rail = stars only; the rest fold under "More") ----------
/// Star state lives on the shared SavedInstance (the same record iOS uses and
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index 618af76..d16ffbd 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -17,6 +17,29 @@ struct RootView: View {
// its own MacHomeSignIn; both read the same Keychain session.
@StateObject private var firstRunSignIn = MacHomeSignIn()
+ // The create-a-space takeover (the rail's + / File ▸ New Space, via
+ // app.createSpaceTick). Non-nil = the instrument owns the window.
+ @State private var createModel: CreateSpaceModel?
+
+ // The goes-to-eleven create flow. The WHOLE pipeline (provision → claim →
+ // setup_complete) runs inside `create`, under "Building your space" — the
+ // instance is persisted (and vault-pushed) before success is ever shown.
+ private func makeCreateModel() -> CreateSpaceModel {
+ CreateSpaceModel(
+ create: { [weak app] in
+ // Carry the name you already go by (your rail's first space).
+ let screenName = await MainActor.run {
+ app?.instancesInRailOrder().first(where: { !$0.isArchived })?.screenName
+ ?? app?.instances.first?.screenName ?? ""
+ }
+ let inst = try await SpaceCreationFlow.run(screenName: screenName)
+ await MainActor.run { app?.addInstance(inst) }
+ return inst
+ },
+ finish: { _, _ in createModel = nil },
+ cancel: { createModel = nil })
+ }
+
var body: some View {
HStack(spacing: 0) {
Sidebar()
@@ -38,6 +61,19 @@ struct RootView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.allowsHitTesting(false)
}
+ // Create-a-space takes over the WHOLE window (Paul's call — the sheet
+ // read small; the haze and dust want the full stage). Item-based
+ // (model != nil), so there's no present-before-content race.
+ .overlay {
+ if let model = createModel {
+ CreateSpaceView(model: model)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(nsColor: .windowBackgroundColor))
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeOut(duration: 0.18), value: createModel == nil)
+ .onChange(of: app.createSpaceTick) { _, _ in createModel = makeCreateModel() }
// ⌘K quick-switch palette, drawn over the whole window (above the web feed).
.overlay { if app.showPalette { CommandPalette() } }
// The photo / video lightboxes, zooming out of their bubble over black.
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift
index 4a66f45..f839425 100644
--- a/ios/LChatMac/Sidebar.swift
+++ b/ios/LChatMac/Sidebar.swift
@@ -17,6 +17,10 @@ struct Sidebar: View {
// Settings button (MacHomeSignIn.signOut: final push, wipe, relaunch).
@StateObject private var signOut = MacHomeSignIn()
@State private var confirmSignOut = false
+ // Create-a-space: the rail's "+" (shown when home offers provisioning and
+ // this Mac is signed in with at least one space — the web's gate). The
+ // takeover itself is RootView's (app.requestCreateSpace bumps the tick).
+ @State private var spacesOffered = false
// The rail shows favourites ONLY when any exist; otherwise up to nine
// spaces. Everything else lives in the "…" menu — archived spaces at its
@@ -40,10 +44,14 @@ struct Sidebar: View {
ForEach(railChats) { chat in
icon(chat)
}
- // Always present: besides the overflow it carries Add a Chat
- // and the home link (the big "+" it replaces looked heavy under
- // the ellipsis).
+ // Always present: besides the overflow it carries the home link
+ // and Sign Out.
moreMenu
+ // The "+" means exactly one thing: create a new space (the
+ // goes-to-eleven flow) — joining stays on the web/sign-in.
+ if spacesOffered && signOut.sessionExists && !app.instances.isEmpty {
+ createButton
+ }
}
// Top inset clears the floating traffic-light controls (title bar hidden).
.padding(.top, 34)
@@ -62,6 +70,19 @@ struct Sidebar: View {
// Sign-ins happen on other surfaces (first-run, Settings, the browser
// handoff) — re-check whenever the rail shows so the item isn't stale.
.onAppear { signOut.refreshSession() }
+ // Whether home offers space creation (a paired provisioner) — checked
+ // once per instance-set change; false hides the "+" entirely.
+ .task(id: "\(app.instances.isEmpty)-\(signOut.sessionExists)") {
+ guard signOut.sessionExists, !app.instances.isEmpty else { return }
+ spacesOffered = await HomeClient().features()
+ }
+ // Re-check the features gate whenever the app comes forward, so one
+ // flaky fetch can't hide the "+" for the whole session.
+ .onReceive(NotificationCenter.default.publisher(
+ for: NSApplication.didBecomeActiveNotification)) { _ in
+ guard !spacesOffered, signOut.sessionExists, !app.instances.isEmpty else { return }
+ Task { spacesOffered = await HomeClient().features() }
+ }
.confirmationDialog(
"Sign out? Your spaces, messages and keys are removed from this Mac. Sign back in to get them back.",
isPresented: $confirmSignOut, titleVisibility: .visible
@@ -97,6 +118,23 @@ struct Sidebar: View {
.onDrop(of: [.text], delegate: ServerDropDelegate(item: chat, dragging: $dragging, app: app))
}
+ // The rail's "+": create a new space. Same quiet-circle chrome as the "…".
+ private var createButton: some View {
+ Button {
+ app.requestCreateSpace()
+ } label: {
+ Image(systemName: "plus")
+ .font(.callout.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 34, height: 34)
+ .background(Circle().fill(Color.secondary.opacity(0.12)))
+ .frame(maxWidth: .infinity)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .railTooltip("Create a New Space")
+ }
+
// Everything not on the rail, behind a quiet ellipsis circle (Discord's
// "more servers" idiom) — as a MENU, not an inline fold: click a space to
// open it, or use its submenu to star/archive; archived spaces group at
@@ -118,8 +156,11 @@ struct Sidebar: View {
Button("Manage Spaces on Eleven…") {
NSWorkspace.shared.open(URL(string: "https://home.elevenmessenger.com/")!)
}
- if signOut.sessionExists {
- Divider()
+ // Gated on a LIVE Keychain check, not the published flag: menu
+ // content evaluates at open, and the flag went stale when sign-in
+ // happened on another surface (the browser handoff) after the
+ // rail's one onAppear refresh — hiding Sign Out entirely.
+ if HomeSessionStore.load() != nil {
Button("Sign Out…") { confirmSignOut = true }
}
} label: {