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>
13 files changed,
+1594
−34
ios/LChat/LChatApp.swift+49 −0ios/LChat/Views/InstanceSidebar.swift+36 −26ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift+923 −0ios/LChatCore/Sources/LChatCore/Crypto.swift+11 −0ios/LChatCore/Sources/LChatCore/HomeClient.swift+52 −0ios/LChatCore/Sources/LChatCore/InviteCrypto.swift+5 −3ios/LChatCore/Sources/LChatCore/LChatAPI.swift+27 −0ios/LChatCore/Sources/LChatCore/SpaceCreate.swift+226 −0ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift+79 −0ios/LChatCore/Tests/LChatCoreTests/SpaceCreateTests.swift+73 −0ios/LChatMac/LChatMacApp.swift+31 −0ios/LChatMac/RootView.swift+36 −0ios/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: { |