eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2#if os(iOS)
3import CoreHaptics
4import UIKit
5#elseif os(macOS)
6import AppKit
7#endif
8
9// πŸ€– The native "goes to eleven" create-a-space instrument β€” a SwiftUI twin of
10// home's web/static/fader.js, shared by iOS (full-screen cover) and Mac (sheet).
11// Same choreography: drag the cap to 10, keep pulling past the gap to build
12// pressure for ~900ms, and the snap to 11 fires the real create; a 950ms wild
13// window overlaps the network wait, then the "Building your space" pane holds
14// (shake + dust cranked) until the provisioner settles. Success rains confetti
15// and asks for the space's name; failure resets the fader with inline copy.
16// Pure math lives in FaderMath (SpaceCreate.swift) so tests pin it to the web.
17//
18// On iOS the drag is a real gesture and the instrument is haptic: a detent tick
19// per notch (rising with the volume), a pressure ramp through the hold, a thud
20// at the snap, and irregular "sputtering" transients for as long as the space
21// is building β€” the phone strains along with the meter.
22
23@MainActor
24public final class CreateSpaceModel: ObservableObject, Identifiable {
25 // Identifiable so presentation is ITEM-based (sheet(item:)/cover(item:)):
26 // isPresented + an optional model raced β€” the sheet could present a beat
27 // before the model state landed, showing an empty white square.
28 public let id = UUID()
29 public enum Stage: Equatable {
30 case slide // the instrument (also hosts snap/build)
31 case ready(host: String) // claimed β€” the confetti beat, then straight in
32 }
33
34 @Published public private(set) var stage: Stage = .slide
35 @Published public var status: String? // inline error line, never a toast
36 // From the snap until the app has switched into the new space, the sheet
37 // must not be escapable (published so interactiveDismissDisabled tracks it).
38 @Published private var creating = false
39 // The render heartbeat: bumped ~60Hz by the driver timer. Any @Published
40 // change invalidates the view, so this alone re-renders everything the sim
41 // moves. (TimelineView(.animation) silently stopped ticking on device β€”
42 // frozen smoke, a slider that ignored drags β€” hence an explicit driver.)
43 @Published private var tick = 0
44
45 // The full pipeline run at the snap: provision + claim (SavedInstance ready).
46 let create: () async throws -> SavedInstance
47 // Name + open: platform persists/selects the instance and dismisses.
48 let finish: (SavedInstance, String) async -> Void
49 let cancel: () -> Void
50
51 public init(create: @escaping () async throws -> SavedInstance,
52 finish: @escaping (SavedInstance, String) async -> Void,
53 cancel: @escaping () -> Void) {
54 self.create = create
55 self.finish = finish
56 self.cancel = cancel
57 }
58
59 // ---- fader simulation (non-published: stepped by the driver, read by render)
60
61 var phase: FaderPhase = .slide
62 var value = 1.0
63 var pressure = 0.0
64 var overpull = false
65 var dragging = false
66 var lastT: TimeInterval?
67 var phaseT0: TimeInterval = 0
68 var shakeX = 0.0, shakeY = 0.0, shakeRot = 0.0
69 var dust = DustField()
70 var confetti = ConfettiField()
71 var ambient = AmbientField()
72 let haptics = CreateHaptics()
73 var stageSize: CGSize = .zero // the screen, reported by the view
74 var reduceMotion = false // env flag, reported by the view
75 var renderTick: Int { tick } // forces the Canvases to redraw by value
76 private var driver: Timer?
77 private var lastDetent = 1
78 private var winddownT0: TimeInterval? // failure ramp-down start
79 // Assistive overpull is armed per-increment with a short deadline: holding
80 // the increment (repeated calls) sustains it toward the snap, a single
81 // flick lapses harmlessly β€” the web's key-held semantics (fader.js clears
82 // overpull on keyup), not a one-tap space creation.
83 private var a11yOverpullUntil: TimeInterval?
84 private var settled: Result<SavedInstance, Error>?
85 private(set) var claimed: SavedInstance?
86
87 public var busy: Bool { creating }
88
89 // Backing out is only offered before the snap β€” once the create has fired
90 // the space is real, and it opens on its own moments later. The ONE gate
91 // shared by the Cancel button and the Mac Esc key, so they can never
92 // disagree about when leaving is safe.
93 public var canCancel: Bool { phase == .slide && stage == .slide }
94
95 // Cancel with the gate folded in β€” what a host's Esc handler calls (the
96 // raw `cancel` closure stays internal, reachable only past the gate).
97 public func requestCancel() {
98 if canCancel { cancel() }
99 }
100
101 /// Start the 60Hz sim driver. Timer fires on the main run loop (.common,
102 /// so it keeps ticking through gesture tracking), OUTSIDE view evaluation β€”
103 /// stepping here may touch @Published state freely.
104 func start() {
105 guard driver == nil else { return }
106 let t = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in
107 MainActor.assumeIsolated {
108 guard let self else { return }
109 self.step(now: Date().timeIntervalSinceReferenceDate,
110 size: self.stageSize, reduceMotion: self.reduceMotion)
111 self.tick &+= 1
112 }
113 }
114 RunLoop.main.add(t, forMode: .common)
115 driver = t
116 }
117
118 func stop() {
119 driver?.invalidate()
120 driver = nil
121 }
122
123 func dragChanged(x: CGFloat, trackWidth: CGFloat) {
124 guard phase == .slide, winddownT0 == nil, trackWidth > 0 else { return }
125 dragging = true
126 value = FaderMath.value(fraction: x / trackWidth)
127 // Arm only when the value has bottomed out at 10 AND the pointer has
128 // physically crossed into the dead gap past the run (fader.js onMove).
129 overpull = value >= 9.99 && x > trackWidth * FaderMath.runEnd + 14
130 let notch = Int(value.rounded())
131 if notch != lastDetent {
132 haptics.detent(notch)
133 lastDetent = notch
134 }
135 }
136
137 func dragEnded() {
138 dragging = false
139 overpull = false
140 a11yOverpullUntil = nil
141 }
142
143 /// The assistive increment: step the dial; at the top, arm the hold with a
144 /// deadline so only a SUSTAINED series of increments reaches eleven.
145 func a11yIncrement(now: TimeInterval = Date().timeIntervalSinceReferenceDate) {
146 guard phase == .slide, winddownT0 == nil else { return }
147 if value >= 9.99 {
148 overpull = true
149 a11yOverpullUntil = now + 0.4
150 } else {
151 value = min(10, value.rounded() + 1)
152 }
153 }
154
155 func a11yDecrement() {
156 guard phase == .slide, winddownT0 == nil else { return }
157 value = max(1, value.rounded() - 1)
158 dragEnded()
159 }
160
161 /// One simulation step, fired by the driver timer at display rate β€” a
162 /// plain event context, so @Published transitions are safe inline.
163 func step(now: TimeInterval, size: CGSize, reduceMotion: Bool) {
164 let dt = min(0.1, max(0, now - (lastT ?? now)))
165 lastT = now
166
167 if phase == .slide {
168 if let t0 = winddownT0 {
169 // A failed create is the power dying: the dial falls from
170 // eleven on a slow exponential curve β€” meter, shake, dust and
171 // the haptic mess all following it down. The error line is
172 // already up (shown the moment the wind-down began).
173 value = 1 + 9 * exp(-(now - t0) / 0.55)
174 let notch = Int(value.rounded())
175 if notch != lastDetent {
176 haptics.detent(notch)
177 lastDetent = notch
178 }
179 haptics.chaos(drive: (value - 1) / 9, now: now)
180 if value <= 1.06 {
181 value = 1
182 winddownT0 = nil
183 }
184 } else {
185 // A lapsed assistive hold disarms (the finger path clears via
186 // dragEnded instead β€” a live drag never carries a deadline).
187 if let until = a11yOverpullUntil, now > until {
188 overpull = false
189 a11yOverpullUntil = nil
190 }
191 if overpull && value >= 9.99 {
192 pressure = min(1, pressure + dt * 1000 / FaderMath.holdMS)
193 haptics.holdPulse(pressure: pressure, now: now)
194 } else {
195 pressure = max(0, pressure - dt * 1000 / 300)
196 }
197 // The volume in your hand: a random mess of taps β€” busier and
198 // harder as the slider climbs, straining through the hold β€”
199 // with the notch detents punching through.
200 haptics.chaos(drive: min(1, pow(max(0, value - 1) / 9, 1.4) * 0.9
201 + pressure * 0.35), now: now)
202 if pressure >= 1 { snapToEleven(now: now) }
203 }
204 }
205
206 if phase == .snap {
207 haptics.sputterTick(now: now)
208 if (now - phaseT0) * 1000 >= FaderMath.snapMS { phase = .build }
209 }
210 if phase == .build {
211 haptics.sputterTick(now: now)
212 if let settled {
213 self.settled = nil
214 switch settled {
215 case .success(let inst):
216 claimed = inst
217 phase = .done
218 haptics.success()
219 if !reduceMotion { confetti.spawn(in: size) }
220 stage = .ready(host: URL(string: inst.origin)?.host ?? inst.origin)
221 // The confetti beat, then straight into the space β€” no
222 // naming step (the admin console renames any time).
223 Task { @MainActor in
224 try? await Task.sleep(nanoseconds: 1_400_000_000)
225 self.open()
226 }
227 case .failure(let error):
228 resetToSlide()
229 // Start the wind-down at eleven with the error line up
230 // immediately β€” the ramp is the mood, not a delay on
231 // finding out what happened.
232 value = 10
233 lastDetent = 10
234 winddownT0 = now
235 status = (error as? LocalizedError)?.errorDescription
236 ?? "Couldn't create a space."
237 creating = false
238 }
239 }
240 }
241
242 // Loudness drives the theatre. Past the snap it's CRANKED (fader.js).
243 let level = FaderMath.level(phase: phase, value: value, pressure: pressure)
244 if reduceMotion || level <= 0 {
245 shakeX = 0; shakeY = 0; shakeRot = 0
246 } else {
247 let mult = (phase == .snap || phase == .build) ? FaderMath.cranked : 1
248 let a = FaderMath.shake * level * mult
249 shakeX = (Double.random(in: -1...1)) * a
250 shakeY = (Double.random(in: -1...1)) * a
251 shakeRot = (Double.random(in: -0.5...0.5)) * 1.6 * level * mult
252 }
253 if !reduceMotion {
254 ambient.step(dt: dt, size: size)
255 dust.step(dt: dt, level: level, size: size, fading: phase == .done)
256 confetti.step(dt: dt, size: size)
257 }
258 }
259
260 private func snapToEleven(now: TimeInterval) {
261 phase = .snap
262 phaseT0 = now
263 value = 10
264 pressure = 0
265 haptics.snap()
266 // Fire the real create NOW β€” the wild window overlaps the network time.
267 Task { @MainActor in
268 self.creating = true
269 self.status = nil
270 do { self.settled = .success(try await self.create()) }
271 catch { self.settled = .failure(error) }
272 }
273 }
274
275 private func resetToSlide() {
276 phase = .slide
277 value = 1
278 pressure = 0
279 overpull = false
280 lastDetent = 1
281 haptics.stopSputter()
282 }
283
284 private var opening = false
285
286 func open() {
287 guard let inst = claimed, !opening else { return }
288 opening = true
289 Task { @MainActor in
290 await finish(inst, "")
291 creating = false
292 }
293 }
294
295 /// The cap's position along the track (fraction of width): pinned past the
296 /// gap at eleven; a small pressure "give" creeps into the gap while held.
297 var capFraction: Double {
298 if phase == .snap || phase == .build || phase == .done { return 1 }
299 return ((value - 1) / 9) * FaderMath.runEnd
300 + (pressure > 0 ? pressure * (1 - FaderMath.runEnd) * 0.35 : 0)
301 }
302}
303
304/// The sheet/cover wrapper BOTH platforms present: it observes the model, so
305/// the escape gate actually re-evaluates when the create starts (a plain
306/// .interactiveDismissDisabled at an unobserving call site froze at its
307/// presentation-time value β€” Escape could kill the sheet mid-create).
308public struct CreateSpaceSheet: View {
309 @ObservedObject var model: CreateSpaceModel
310
311 public init(model: CreateSpaceModel) { self.model = model }
312
313 public var body: some View {
314 CreateSpaceView(model: model)
315 .interactiveDismissDisabled(model.busy)
316 }
317}
318
319public struct CreateSpaceView: View {
320 @ObservedObject var model: CreateSpaceModel
321 @Environment(\.accessibilityReduceMotion) private var reduceMotion
322
323 public init(model: CreateSpaceModel) { self.model = model }
324
325 public var body: some View {
326 // Rendering is pure: the model's driver timer steps the sim at 60Hz
327 // and bumps a published tick, which re-evaluates this body.
328 content(now: Date().timeIntervalSinceReferenceDate)
329 .onAppear {
330 model.reduceMotion = reduceMotion
331 model.haptics.prepare()
332 model.start()
333 }
334 .onChange(of: reduceMotion) { _, r in model.reduceMotion = r }
335 .onDisappear {
336 model.stop()
337 model.haptics.stopAll()
338 }
339 }
340
341 // Solid, not material: the card should sit WHITE on the hazy room in
342 // light mode (grey-on-grey read muddy), and properly elevated in dark.
343 private var cardColor: Color {
344 #if os(iOS)
345 Color(.systemBackground)
346 #else
347 Color(nsColor: .textBackgroundColor)
348 #endif
349 }
350
351 private func content(now: TimeInterval) -> some View {
352 ZStack {
353 // Home's ambient haze β€” drifting smoke puffs + rising motes β€”
354 // behind everything, theme-aware (bootscene.js's quiet sibling).
355 // Every canvas carries the tick: the field is a class (identical
356 // reference frame-to-frame), so without a changing value SwiftUI
357 // would skip the redraw and the scene freezes. The whole particle
358 // stage is FULL-BLEED (ignoresSafeArea) and the sim runs on that
359 // size, so dust genuinely enters from below the physical screen
360 // edge β€” not from the safe-area line above the home indicator.
361 if !reduceMotion {
362 ZStack {
363 AmbientCanvas(field: model.ambient, tick: model.renderTick)
364 DustCanvas(field: model.dust, tick: model.renderTick)
365 // Measured INSIDE the safe-area-ignoring container so the
366 // sim's floor is the physical screen bottom. (Measuring
367 // via .overlay outside reported the safe-area size β€” the
368 // dust's "below the edge" rest line floated ~70pt up,
369 // visible as a straight row of dots.)
370 GeometryReader { geo in
371 Color.clear
372 .onAppear { model.stageSize = geo.size }
373 .onChange(of: geo.size) { _, s in model.stageSize = s }
374 }
375 }
376 .allowsHitTesting(false)
377 .ignoresSafeArea()
378 }
379 // The instrument sits on a card, like home's main.card β€” the card
380 // is what shakes.
381 card(now: now)
382 .background(cardColor,
383 in: RoundedRectangle(cornerRadius: 18, style: .continuous))
384 .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
385 .strokeBorder(Color.primary.opacity(0.08)))
386 .shadow(color: .black.opacity(0.12), radius: 18, y: 8)
387 .padding(.horizontal, 16)
388 .offset(x: model.shakeX, y: model.shakeY)
389 .rotationEffect(.degrees(model.shakeRot))
390 if !reduceMotion {
391 ConfettiCanvas(field: model.confetti, tick: model.renderTick)
392 .allowsHitTesting(false)
393 .ignoresSafeArea()
394 }
395 }
396 // Backing out is only offered before the snap (canCancel β€” the same
397 // gate the Mac Esc key uses).
398 .overlay(alignment: .topLeading) {
399 if model.canCancel {
400 Button("Cancel") { model.cancel() }
401 .buttonStyle(.plain)
402 .foregroundStyle(.secondary)
403 .padding(20)
404 }
405 }
406 }
407
408 @ViewBuilder private func card(now: TimeInterval) -> some View {
409 VStack(spacing: 22) {
410 switch model.stage {
411 case .slide:
412 if model.phase == .build {
413 buildingPane
414 } else {
415 VStack(spacing: 6) {
416 Text("Slide to create")
417 .font(.title2.weight(.semibold))
418 Text("Your new space will be created immediately.")
419 .font(.callout)
420 .foregroundStyle(.secondary)
421 }
422 }
423 meter(now: now)
424 fader
425 if let status = model.status {
426 Text(status)
427 .font(.footnote)
428 .foregroundStyle(.red)
429 .multilineTextAlignment(.center)
430 }
431 case .ready(let host):
432 readyPane(host: host)
433 }
434 }
435 .padding(28)
436 .frame(maxWidth: 440)
437 }
438
439 private var buildingPane: some View {
440 VStack(spacing: 10) {
441 ProgressView()
442 .controlSize(.large)
443 Text("Hold on…")
444 .font(.title3.weight(.semibold))
445 Text("Building your space")
446 .font(.callout)
447 .foregroundStyle(.secondary)
448 }
449 .padding(.bottom, 6)
450 }
451
452 // The confetti beat: no forms, no buttons β€” the space opens on its own a
453 // moment later (naming lives in the admin console whenever they want it).
454 private func readyPane(host: String) -> some View {
455 VStack(spacing: 14) {
456 Image(systemName: "checkmark.circle.fill")
457 .font(.system(size: 44))
458 .foregroundStyle(.green)
459 Text("Your space is ready")
460 .font(.title2.weight(.semibold))
461 Text(host)
462 .font(.callout.monospaced())
463 .foregroundStyle(.secondary)
464 ProgressView()
465 .controlSize(.small)
466 .padding(.top, 6)
467 }
468 }
469
470 // ---- the instrument ------------------------------------------------------
471
472 /// The 11-segment LED meter: green 1–7, amber 8–9, red 10–11 (desk
473 /// vernacular); every LED lights through the snap/build, with #11 in a
474 /// 140ms "fury" flicker for the whole window.
475 private func meter(now: TimeInterval) -> some View {
476 let hot = model.phase == .snap || model.phase == .build
477 let lit = hot ? 11 : Int(model.value.rounded())
478 return HStack(spacing: 3) {
479 ForEach(1...11, id: \.self) { n in
480 let color: Color = n <= 7 ? .green : n <= 9 ? .orange : .red
481 let on = n <= lit
482 let fury = hot && n == 11 && Int(now / 0.14) % 2 == 1
483 RoundedRectangle(cornerRadius: 1.5)
484 .fill(on ? color : Color.secondary.opacity(0.12))
485 .frame(height: 7)
486 .opacity(fury ? 0.25 : 1)
487 .shadow(color: on ? color.opacity(0.55) : .clear, radius: on ? 2.5 : 0)
488 }
489 }
490 .padding(.bottom, 2)
491 }
492
493 /// A scale mark's fractional x: 1..10 across the run, 11 pinned past the gap.
494 private func markX(_ n: Int) -> Double {
495 n == 11 ? 1.0 : (Double(n - 1) / 9) * FaderMath.runEnd
496 }
497
498 private var fader: some View {
499 VStack(spacing: 8) {
500 // The instrument: slot + ticks + cap. The drag lives on the WHOLE
501 // track (local coordinates β€” grab the cap or land anywhere), which
502 // is also what fixed dragging: a named coordinate space under the
503 // per-frame TimelineView rebuild resolved wrong on device.
504 GeometryReader { geo in
505 let w = geo.size.width
506 let midY = geo.size.height / 2
507 ZStack(alignment: .leading) {
508 RoundedRectangle(cornerRadius: 3)
509 .fill(Color.secondary.opacity(0.22))
510 .frame(height: 6)
511 .overlay(RoundedRectangle(cornerRadius: 3)
512 .strokeBorder(Color.black.opacity(0.12), lineWidth: 0.5))
513 .frame(maxHeight: .infinity, alignment: .center)
514 ForEach(1...11, id: \.self) { n in
515 Rectangle()
516 .fill(Color.secondary.opacity(0.55))
517 .frame(width: 1.5, height: n == 1 || n == 11 ? 14 : 9)
518 .position(x: markX(n) * w, y: midY)
519 }
520 cap
521 .position(x: min(max(model.capFraction * w, 14), w), y: midY)
522 }
523 .contentShape(Rectangle())
524 .gesture(
525 DragGesture(minimumDistance: 0)
526 .onChanged { v in model.dragChanged(x: v.location.x, trackWidth: w) }
527 .onEnded { _ in model.dragEnded() }
528 )
529 }
530 .frame(height: 48)
531 // The scale, on its own row so the cap never sits on the numbers.
532 GeometryReader { geo in
533 let hot = model.phase == .snap || model.phase == .build
534 ForEach(1...11, id: \.self) { n in
535 let arming = n == 11 && (model.overpull || hot)
536 Text("\(n)")
537 .font(.system(size: 11, weight: n == 11 ? .heavy : .semibold))
538 .foregroundStyle(arming ? Color.red
539 : (Int(model.value.rounded()) >= n ? Color.primary : Color.secondary))
540 .scaleEffect(hot && n == 11 ? 1.4 : 1)
541 .position(x: markX(n) * geo.size.width, y: 8)
542 }
543 }
544 .frame(height: 16)
545 }
546 .accessibilityElement()
547 .accessibilityLabel("Volume slider β€” slide to eleven to create your space")
548 .accessibilityAdjustableAction { direction in
549 // The assistive path: step to 10, then KEEP incrementing to hold
550 // toward eleven (each arm lapses in 0.4s β€” fader.js's key-held
551 // overpull, never a one-tap create).
552 switch direction {
553 case .increment: model.a11yIncrement()
554 case .decrement: model.a11yDecrement()
555 @unknown default: break
556 }
557 }
558 }
559
560 /// The mixing-console cap: gradient body, two grooves, a quiet accent stripe.
561 private var cap: some View {
562 ZStack {
563 RoundedRectangle(cornerRadius: 7, style: .continuous)
564 .fill(LinearGradient(colors: [Color(white: 0.62), Color(white: 0.36)],
565 startPoint: .top, endPoint: .bottom))
566 .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous)
567 .strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
568 .frame(width: 28, height: 44)
569 .shadow(color: .black.opacity(0.3), radius: 3, y: 2)
570 VStack {
571 Rectangle().fill(Color.black.opacity(0.3)).frame(width: 18, height: 1)
572 Spacer()
573 Rectangle().fill(Color.black.opacity(0.3)).frame(width: 18, height: 1)
574 }
575 .frame(width: 28, height: 24)
576 Rectangle()
577 .fill(Color.accentColor.opacity(0.9))
578 .frame(width: 2, height: 24)
579 }
580 .allowsHitTesting(false) // the track owns the gesture
581 }
582}
583
584// ---- dust + confetti (fader.js's stage-dust canvas, card-scoped) -------------
585
586struct DustParticle {
587 var x: Double, y: Double, vx: Double, vy: Double, r: Double, accent: Bool
588}
589
590/// Stage dust that lives OFFSTAGE, just below the bottom edge β€” the screen
591/// starts clean, and turning the volume up kicks motes into view from below
592/// (fader.js initDust, floor-shifted): gravity 0.62/frame, kick odds
593/// 0.02 + levelΒ·0.2, bigger jumps past eleven. A settled mote rests below the
594/// edge again, so silence always empties the stage.
595final class DustField {
596 var particles: [DustParticle] = []
597 var fade = 1.0
598 /// How far below the visible edge a resting mote sits β€” deep enough that
599 /// the launch reads as coming from somewhere, not from the edge itself.
600 static let restDepth = 26.0
601 private var seeded = false
602
603 func step(dt: TimeInterval, level: Double, size: CGSize, fading: Bool) {
604 guard size.width > 0, size.height > 0 else { return }
605 let f = dt * 60 // web steps in ~frame units
606 let floor = size.height + Self.restDepth
607 if !seeded {
608 seeded = true
609 particles = (0..<70).map { _ in
610 DustParticle(x: .random(in: 0...size.width), y: floor,
611 vx: 0, vy: 0,
612 r: .random(in: 0.8...2.2), accent: Double.random(in: 0...1) < 0.18)
613 }
614 }
615 if fading { fade = max(0, fade - dt / 0.6) }
616 let kickOdds = 0.02 + level * 0.2
617 for i in particles.indices {
618 var p = particles[i]
619 // Only the volume lifts a resting mote into view. Jump height
620 // scales hard with the dial: waist-high mid-scale, ~2/3 screen at
621 // 10, and past eleven (level 1.2) they can clear the whole thing.
622 if p.vy == 0, p.y >= floor, level > 0.02, Double.random(in: 0...1) < kickOdds * f {
623 let peak = level > 1 ? Double.random(in: 0.65...1.05) * size.height
624 : level * level * size.height * 0.85
625 p.vy = -sqrt(max(0.1, 2 * 0.62 * (peak + Self.restDepth))) * .random(in: 0.6...1)
626 p.vx = .random(in: -0.8...0.8)
627 }
628 p.vy += 0.62 * f
629 p.x += p.vx * f
630 p.y += p.vy * f
631 if p.y >= floor {
632 p.y = floor
633 // Bounce while it still has energy; otherwise settle offstage.
634 if p.vy > 1.2 { p.vy *= -0.42 } else { p.vy = 0; p.vx = 0 }
635 }
636 if p.x < 0 { p.x += size.width }
637 if p.x > size.width { p.x -= size.width }
638 particles[i] = p
639 }
640 }
641}
642
643struct DustCanvas: View {
644 let field: DustField
645 let tick: Int // changes every frame so the redraw is never skipped
646
647 var body: some View {
648 Canvas { ctx, size in
649 for p in field.particles where p.y < size.height {
650 let rect = CGRect(x: p.x - p.r, y: p.y - p.r, width: p.r * 2, height: p.r * 2)
651 // Solid and dark on purpose β€” the kicked stage dust must read
652 // as a different thing from the pale ambient motes behind it.
653 let color: Color = p.accent ? .accentColor : .primary
654 ctx.fill(Path(ellipseIn: rect),
655 with: .color(color.opacity(0.7 * field.fade)))
656 }
657 }
658 }
659}
660
661// ---- ambient haze (home's bootscene, the quiet sibling) ----------------------
662
663struct AmbientPuff {
664 var x: Double, y: Double, vx: Double, r: Double, phase: Double, alpha: Double
665}
666
667struct AmbientMote {
668 var x: Double, y: Double, vy: Double, sway: Double, r: Double, alpha: Double
669}
670
671/// Home's ambient background, ported small: soft smoke puffs drifting sideways
672/// and dust motes rising through them. Runs the whole time the create screen
673/// is up β€” the room the instrument lives in, not a volume effect.
674final class AmbientField {
675 var puffs: [AmbientPuff] = []
676 var motes: [AmbientMote] = []
677 var t = 0.0
678 private var seeded = false
679
680 func step(dt: TimeInterval, size: CGSize) {
681 guard size.width > 0, size.height > 0 else { return }
682 t += dt
683 if !seeded {
684 seeded = true
685 puffs = (0..<18).map { _ in
686 AmbientPuff(x: .random(in: -80...size.width + 80),
687 y: .random(in: size.height * 0.2...size.height * 1.05),
688 vx: .random(in: -6...6).clampedAwayFromZero(min: 2),
689 r: .random(in: 80...170),
690 phase: .random(in: 0...(2 * .pi)),
691 alpha: .random(in: 0.07...0.14))
692 }
693 motes = (0..<36).map { _ in
694 AmbientMote(x: .random(in: 0...size.width),
695 y: .random(in: 0...size.height),
696 vy: .random(in: 8...22),
697 sway: .random(in: 0...(2 * .pi)),
698 r: .random(in: 1...2.4),
699 alpha: .random(in: 0.1...0.22))
700 }
701 }
702 for i in puffs.indices {
703 puffs[i].x += puffs[i].vx * dt
704 if puffs[i].x < -160 { puffs[i].x = size.width + 160 }
705 if puffs[i].x > size.width + 160 { puffs[i].x = -160 }
706 }
707 for i in motes.indices {
708 motes[i].y -= motes[i].vy * dt
709 motes[i].x += sin(t * 0.6 + motes[i].sway) * 6 * dt
710 if motes[i].y < -4 {
711 motes[i].y = size.height + 4
712 motes[i].x = .random(in: 0...size.width)
713 }
714 }
715 }
716}
717
718struct AmbientCanvas: View {
719 let field: AmbientField
720 let tick: Int // changes every frame so the redraw is never skipped
721 @Environment(\.colorScheme) private var scheme
722
723 var body: some View {
724 // Dark room: white smoke, light motes. Light room: grey smoke, soft
725 // grey motes β€” bootscene.js's two-theme rule. Motes stay a smidge
726 // lighter than the stage dust so the two read as different things.
727 let smoke: Color = scheme == .dark ? .white : Color(white: 0.3)
728 let mote: Color = scheme == .dark ? Color(white: 0.85) : Color(white: 0.45)
729 ZStack {
730 Canvas { ctx, _ in
731 for p in field.puffs {
732 let bob = sin(field.t * 0.4 + p.phase) * 10
733 let rect = CGRect(x: p.x - p.r, y: p.y + bob - p.r * 0.6,
734 width: p.r * 2, height: p.r * 1.2)
735 ctx.fill(Path(ellipseIn: rect), with: .color(smoke.opacity(p.alpha)))
736 }
737 }
738 .blur(radius: 28)
739 Canvas { ctx, _ in
740 for m in field.motes {
741 let rect = CGRect(x: m.x - m.r, y: m.y - m.r, width: m.r * 2, height: m.r * 2)
742 ctx.fill(Path(ellipseIn: rect), with: .color(mote.opacity(m.alpha)))
743 }
744 }
745 }
746 .ignoresSafeArea()
747 }
748}
749
750private extension Double {
751 /// Keep a random drift speed from being visually dead-stopped.
752 func clampedAwayFromZero(min m: Double) -> Double {
753 abs(self) < m ? (self < 0 ? -m : m) : self
754 }
755}
756
757struct ConfettiPiece {
758 var x: Double, y: Double, vx: Double, vy: Double
759 var w: Double, h: Double, spin: Double, angle: Double
760 var hue: Double, delay: Double, life: Double
761}
762
763/// Success only: 75 corner-cannon pieces + 170 sky rain (fader.js spawnConfetti).
764final class ConfettiField {
765 var pieces: [ConfettiPiece] = []
766
767 func spawn(in size: CGSize) {
768 guard size.width > 0 else { return }
769 var out: [ConfettiPiece] = []
770 for i in 0..<75 { // corner cannons
771 let left = i % 2 == 0
772 out.append(ConfettiPiece(
773 x: left ? -4 : size.width + 4, y: size.height * 0.9,
774 vx: (left ? 1 : -1) * .random(in: 3...8), vy: -.random(in: 11...18),
775 w: .random(in: 5...11), h: .random(in: 3...8),
776 spin: .random(in: -0.2...0.2), angle: .random(in: 0...(2 * .pi)),
777 hue: .random(in: 0...1), delay: 0, life: 4.6))
778 }
779 for _ in 0..<170 { // sky-wide rain
780 out.append(ConfettiPiece(
781 x: .random(in: 0...size.width), y: -.random(in: 0...30),
782 vx: .random(in: -0.6...0.6), vy: .random(in: 1...3),
783 w: .random(in: 5...11), h: .random(in: 3...8),
784 spin: .random(in: -0.2...0.2), angle: .random(in: 0...(2 * .pi)),
785 hue: .random(in: 0...1), delay: .random(in: 0...1.8), life: 4.6))
786 }
787 pieces = out
788 }
789
790 func step(dt: TimeInterval, size: CGSize) {
791 guard !pieces.isEmpty else { return }
792 let f = dt * 60
793 for i in pieces.indices {
794 var p = pieces[i]
795 if p.delay > 0 { p.delay -= dt; pieces[i] = p; continue }
796 p.life -= dt
797 p.vy += 0.18 * f
798 p.x += p.vx * f
799 p.y += p.vy * f
800 p.angle += p.spin * f
801 pieces[i] = p
802 }
803 pieces.removeAll { $0.life <= 0 || $0.y > size.height + 20 }
804 }
805}
806
807struct ConfettiCanvas: View {
808 let field: ConfettiField
809 let tick: Int // changes every frame so the redraw is never skipped
810
811 var body: some View {
812 Canvas { ctx, _ in
813 for p in field.pieces where p.delay <= 0 {
814 var c = ctx
815 c.translateBy(x: p.x, y: p.y)
816 c.rotate(by: .radians(p.angle))
817 let rect = CGRect(x: -p.w / 2, y: -p.h / 2, width: p.w, height: p.h)
818 let color = Color(hue: p.hue, saturation: 0.92, brightness: 0.85)
819 c.fill(Path(rect), with: .color(color.opacity(min(1, p.life / 1.2))))
820 }
821 }
822 }
823}
824
825// ---- haptics ------------------------------------------------------------------
826
827/// The instrument's feel. iOS: CoreHaptics transients β€” a detent per notch
828/// (rising with the volume), a pressure ramp through the hold, a thud at the
829/// snap, and irregular "sputtering" ticks for the whole build wait. Falls back
830/// to UIImpactFeedbackGenerator when the engine is unavailable. Mac: the
831/// trackpad's alignment tick per notch (NSHapticFeedbackManager).
832public final class CreateHaptics {
833 #if os(iOS)
834 private var engine: CHHapticEngine?
835 private let impact = UIImpactFeedbackGenerator(style: .rigid)
836 private let notify = UINotificationFeedbackGenerator()
837 #endif
838 private var nextHold: TimeInterval = 0
839 private var nextSputter: TimeInterval = 0
840 private var nextChaos: TimeInterval = 0
841 private var sputtering = false
842
843 public init() {}
844
845 func prepare() {
846 #if os(iOS)
847 guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
848 engine = try? CHHapticEngine()
849 engine?.resetHandler = { [weak self] in try? self?.engine?.start() }
850 try? engine?.start()
851 impact.prepare()
852 #endif
853 }
854
855 /// One transient tap. Intensity/sharpness 0…1.
856 private func tap(intensity: Double, sharpness: Double) {
857 #if os(iOS)
858 if let engine,
859 let pattern = try? CHHapticPattern(events: [CHHapticEvent(
860 eventType: .hapticTransient,
861 parameters: [
862 CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(intensity)),
863 CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(sharpness)),
864 ], relativeTime: 0)], parameters: []),
865 let player = try? engine.makePlayer(with: pattern) {
866 try? player.start(atTime: 0)
867 } else {
868 impact.impactOccurred(intensity: intensity)
869 }
870 #endif
871 }
872
873 /// A notch crossed: a tick that firms up as the volume climbs.
874 func detent(_ n: Int) {
875 tap(intensity: 0.25 + 0.75 * Double(n) / 11, sharpness: 0.5)
876 #if os(macOS)
877 NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)
878 #endif
879 }
880
881 /// The hold: pulses every ~70ms, harder as pressure builds toward the snap.
882 func holdPulse(pressure: Double, now: TimeInterval) {
883 guard now >= nextHold else { return }
884 nextHold = now + 0.07
885 tap(intensity: 0.2 + 0.8 * pressure, sharpness: 0.7)
886 }
887
888 /// The volume itself, in the hand: a RANDOM MESS of taps β€” never a steady
889 /// rhythm. `drive` (0…1) sets how busy and how hard: sparse light ticks
890 /// low on the scale, a dense violent clatter by 10 and through the hold.
891 func chaos(drive: Double, now: TimeInterval) {
892 guard drive > 0.03, now >= nextChaos else { return }
893 let gapLo = max(0.028, 0.16 - 0.12 * drive)
894 let gapHi = max(gapLo + 0.02, 0.34 - 0.26 * drive)
895 nextChaos = now + .random(in: gapLo...gapHi)
896 tap(intensity: min(1, drive * .random(in: 0.35...1.25)),
897 sharpness: .random(in: 0.15...1))
898 }
899
900 /// Eleven. A full-strength thud; the sputter starts on the next tick.
901 func snap() {
902 tap(intensity: 1, sharpness: 1)
903 sputtering = true
904 #if os(macOS)
905 NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .now)
906 #endif
907 }
908
909 /// The build wait: irregular "spitty" transients β€” random strength and
910 /// spacing, like a machine straining β€” until success/failure stops it.
911 func sputterTick(now: TimeInterval) {
912 guard sputtering, now >= nextSputter else { return }
913 nextSputter = now + .random(in: 0.05...0.19)
914 tap(intensity: .random(in: 0.25...1), sharpness: .random(in: 0.3...1))
915 }
916
917 func stopSputter() { sputtering = false }
918
919 func stopAll() {
920 stopSputter()
921 #if os(iOS)
922 // The engine keeps a hardware session alive until stopped β€” dismissal
923 // must actually shut it down, not just quiet the pattern flags.
924 engine?.stop()
925 engine = nil
926 #endif
927 }
928
929 func success() {
930 stopSputter()
931 #if os(iOS)
932 notify.notificationOccurred(.success)
933 #endif
934 }
935}
936