{"$schema":"https://swiftpieces.com/schema/registry-item.json","name":"AssistantOrb","slug":"assistant-orb","type":"registry:component","title":"Assistant Orb","description":"A dark glass ball with a Siri-style voice wave inside, locked to the thinking state; four thin sine membranes travel through the equator of a refracting shell, drawn by a Metal color shader. The sphere never moves, only the wave, and a palette change crossfades its colours.","category":"ai","license":"MIT+Commons-Clause","version":"1.0.0","minIOSVersion":"17.0","swiftToolsVersion":"6.1","tags":["ai","orb","shader","metal","thinking"],"dependencies":[],"registryDependencies":[],"spmDependencies":[],"requiredCapabilities":[],"infoPlist":{},"assets":[],"shaders":[{"path":"registry/swift/ai/AssistantOrb.metal","type":"registry:shader","target":"SwiftPieces/AI/AssistantOrb.metal","content":"#include <metal_stdlib>\nusing namespace metal;\n\n// Siri Wave orb, thinking phase. Four thin sine membranes travel left to right through the\n// equator of a dark glass ball, seen through a refracting shell. The values are the Siri Wave\n// rest look with the thinking remap applied (speed x 1.38, amplitude x 0.95) and are fixed:\n// the sphere never moves, only the wave inside it. Colours are uniforms so a screen can tint\n// the orb for what it portrays (a mood, a breath); motion never changes.\n// Helpers are file-local (static) so several orb kernels can share one Xcode target.\n\nnamespace assistantOrbSiri {\n\nconstant int   kBands      = 4;\nconstant float kSpeed      = 1.311;\nconstant float kSeparation = 2.05;\nconstant float kSpread     = 0.0;\nconstant float kDetune     = 0.05;\nconstant float kAmplitude  = 0.266;\nconstant float kFrequency  = 1.45;\nconstant float kWidth      = 0.115;\nconstant float kTaper      = 1.75;\nconstant float kGround     = 0.08;\nconstant float kModelling  = 0.1;\nconstant float kRefraction = 0.5;\nconstant float kDispersion = 0.72;\nconstant float kSheen      = 0.36;\nconstant float kExposure   = 2.15;\nconstant float kRadius     = 0.45;\n\n// The eight colours: four bands left to right in the stack, the ground wash, the crest and\n// specular, and the cool and warm halves of the rim dispersion.\nstruct OrbColors { float3 band0, band1, band2, band3, ground, crest, rimCool, rimWarm; };\n\nstatic float sat(float x) { return clamp(x, 0.0, 1.0); }\nstatic float3 sat3(float3 c) { return clamp(c, float3(0.0), float3(1.0)); }\n\n// Push a colour away from its luma; 0 is identity.\nstatic float3 saturateColor(float3 c, float amount) {\n  float l = dot(c, float3(0.2126, 0.7152, 0.0722));\n  return sat3(mix(float3(l), c, 1.0 + amount));\n}\n\n// How far the shell pulls a sample inward, given its depth below the rim.\nstatic float glassProfile(float depth, float rimWidth) {\n  float d = sat(depth / max(rimWidth, 0.001));\n  return pow(1.0 - sqrt(max(1.0 - (1.0 - d) * (1.0 - d), 0.0)), 0.68);\n}\n\n// One specular lobe on the glass.\nstatic float lobe(float2 n, float2 dir, float cut, float power) {\n  return pow(sat((dot(n, dir) - cut) / max(1.0 - cut, 0.001)), power);\n}\n\n// Directional light and shadow across the body.\nstatic float3 modelBody(float3 c, float2 p, float3 crest) {\n  c = mix(c, crest, kModelling * 0.22 * smoothstep(0.15, 1.15, dot(p, float2(-0.32, 0.78))));\n  c *= 1.0 - kModelling * 0.34 * smoothstep(-0.1, 1.2, dot(p, float2(0.45, -0.62)));\n  c *= 1.0 - kModelling * 0.22 * smoothstep(0.72, 1.08, length(p));\n  return sat3(c);\n}\n\n// The interior, in ball space.\nstatic float3 interior(float2 p, float t, OrbColors k) {\n  // One envelope for every band: exactly zero at the rim.\n  float env = pow(sat(1.0 - p.x * p.x), kTaper);\n  float drift = t * 2.4;\n  float mid = (float(kBands) - 1.0) * 0.5;\n\n  float total = 0.0;\n  float dominant = 0.0;\n  float3 spectral = float3(0.0);\n  for (int i = 0; i < kBands; i++) {\n    float centred = float(i) - mid;\n    float amp = kAmplitude * (1.0 - abs(centred) * kDetune * 1.1)\n                * (0.82 + 0.18 * sin(t * 0.48 + centred * 1.1));\n    float wave = sin(p.x * kFrequency * (1.0 + centred * kDetune) + drift + centred * kSeparation);\n    float bw = kWidth * (1.0 + abs(centred) * 0.55);\n    float d = p.y - (env * amp * wave + centred * kSpread);\n    float w = exp(-d * d / max(bw * bw, 0.0001)) * env;\n\n    float3 tint = k.band0;\n    if (i == 1) tint = k.band1;\n    else if (i == 2) tint = k.band2;\n    else if (i == 3) tint = k.band3;\n    // Squared weights: an overlap keeps the nearer band's colour instead of averaging to grey.\n    spectral += tint * w * w;\n    dominant += w * w;\n    total += w;\n  }\n  spectral /= max(dominant, 0.0001);\n\n  // Thin white crest on the main sine.\n  float dMain = p.y - env * kAmplitude * sin(p.x * kFrequency + drift);\n  float crest = exp(-dMain * dMain / 0.0028) * env;\n\n  float3 col = mix(float3(0.0), k.ground, smoothstep(-0.82, 0.82, p.y)) * kGround;\n  col += spectral * (1.0 - exp(-total * 0.7)) * env * 1.14;\n  col += k.crest * crest * 0.18;\n  col /= 1.0 + col * 0.19;\n  return modelBody(col, p, k.crest);\n}\n\n} // namespace assistantOrbSiri\n\n// position: SwiftUI passes this; y grows downward and is flipped below.\n// bounds:   pass `.boundingRect`.\n// time:     seconds since the view appeared. Keep it small; Float loses precision past ~1e5.\n// colours:  linear-ish sRGB 0..1 via .float3. The Siri defaults are #2b5cff #ff4fa3 #ff6a2b\n//           #e3170a, ground #12082e, crest #ffffff, rim #4d8aff / #ff4a1a.\n[[ stitchable ]]\nhalf4 assistantOrbSiriWave(float2 position, half4 currentColor, float4 bounds, float time,\n                float3 band0, float3 band1, float3 band2, float3 band3,\n                float3 ground, float3 crest, float3 rimCool, float3 rimWarm) {\n  using namespace assistantOrbSiri;\n  OrbColors k = { band0, band1, band2, band3, ground, crest, rimCool, rimWarm };\n  float2 res = bounds.zw;\n  float2 fc = position - bounds.xy;\n  fc.y = res.y - fc.y;\n\n  float m = min(res.x, res.y);\n  float2 uv = (fc - 0.5 * res) / m;\n  float px = 1.0 / m;\n  float t = time * kSpeed;\n\n  float2 p = uv / kRadius;\n  float pd = length(p);\n  float2 nrm = pd > 0.0001 ? p / pd : float2(0.0, 1.0);\n  float ball = 1.0 - smoothstep(1.0 - px / kRadius, 1.0 + px / kRadius, pd);\n\n  float edgeDepth = max(1.0 - pd, 0.0);\n  float profile = glassProfile(edgeDepth, 0.05 + 0.42 * kDispersion);\n  float2 rp = p - nrm * (1.15 * kRefraction * profile);\n\n  float3 col = interior(rp, t, k);\n\n  // Chromatic split at the rim: red from inside, green centred, blue from outside.\n  float split = 0.14 * kDispersion * kRefraction * profile;\n  if (split > 0.0008) {\n    float3 lo = interior(rp - nrm * split, t, k);\n    float3 up = interior(rp + nrm * split, t, k);\n    col = float3(lo.r, col.g, up.b);\n  }\n\n  float rim = pow(1.0 - smoothstep(0.0, 0.026 + 0.075 * kDispersion, edgeDepth), 1.8) * ball;\n  col = mix(col, k.crest, rim * kRefraction * 0.28);\n\n  float disp = rim * kDispersion * 1.12;\n  col = mix(col, k.rimCool, disp * lobe(nrm, normalize(float2(0.84, 0.54)), -0.32, 1.8));\n  col = mix(col, k.rimWarm, disp * lobe(nrm, normalize(float2(-0.62, -0.78)), -0.28, 2.0));\n  col *= 1.0 - rim * 0.12 * max(dot(nrm, float2(0.45, -0.89)), 0.0);\n\n  col = mix(col, k.crest, sat(rim * lobe(nrm, normalize(float2(-0.68, 0.73)), 0.2, 2.8) * kSheen * 1.4));\n  col = mix(col, k.rimCool, sat(rim * lobe(nrm, normalize(float2(0.74, -0.67)), 0.4, 3.6) * kSheen));\n\n  // Circle mask with a 1px soft edge.\n  float mask = 1.0 - smoothstep(kRadius - px, kRadius + px, length(uv));\n  col = saturateColor(sat3(col * kExposure), 0.32);\n  return half4(half3(col * mask), half(mask));\n}\n"}],"files":[{"path":"registry/swift/ai/AssistantOrb.swift","type":"registry:component","target":"SwiftPieces/AI/AssistantOrb.swift","content":"import SwiftUI\n\n/// The assistant's mark while it thinks. Copy `AssistantOrb.metal` alongside this file; its `assistantOrbSiriWave` kernel compiles into your app's default Metal library.\n///\n/// - Parameters:\n///   - size: Diameter of the ball in points. 160 by default; the view is always a square of this side.\n///   - palette: The eight colours of the wave, ground, crest and rim. `.siri` by default; build your own with `Palette(bands:ground:crest:rimCool:rimWarm:)` from hex or `Color`. A change crossfades over 0.8s.\npublic struct AssistantOrb: View {\n    @Environment(\\.accessibilityReduceMotion) private var reduceMotion\n\n    private let size: CGFloat\n    private let palette: Palette\n\n    public init(size: CGFloat = 160, palette: Palette = .siri) {\n        self.size = size\n        self.palette = palette\n    }\n\n    public var body: some View {\n        WaveSurface(palette: palette)\n            .frame(width: size, height: size)\n            .animation(reduceMotion ? nil : .easeInOut(duration: 0.8), value: palette)\n            .allowsHitTesting(false)\n            .accessibilityElement(children: .ignore)\n            .accessibilityLabel(\"Assistant\")\n            .accessibilityValue(\"Thinking\")\n            .accessibilityAddTraits(.updatesFrequently)\n    }\n\n    /// The orb's eight colours: four bands left to right in the stack, the ground wash inside the\n    /// glass, the crest and specular, and the cool and warm halves of the rim.\n    public struct Palette: Hashable, Sendable {\n        /// sRGB components 0…1, in kernel order: band0…3, ground, crest, rimCool, rimWarm.\n        public var rgb: [SIMD3<Float>]\n\n        public init(bands: [Color], ground: Color, crest: Color = .white, rimCool: Color, rimWarm: Color) {\n            let four = (0..<4).map { bands.isEmpty ? crest : bands[min($0, bands.count - 1)] }\n            rgb = (four + [ground, crest, rimCool, rimWarm]).map(Self.components)\n        }\n\n        /// Hex form, `0x2b5cff`.\n        public init(bands: [UInt32], ground: UInt32, crest: UInt32 = 0xffffff, rimCool: UInt32, rimWarm: UInt32) {\n            let four = (0..<4).map { bands.isEmpty ? crest : bands[min($0, bands.count - 1)] }\n            rgb = (four + [ground, crest, rimCool, rimWarm]).map(Self.components)\n        }\n\n        /// The Siri Wave colours: electric blue, hot pink, orange and red over deep violet.\n        public static let siri = Palette(bands: [0x2b5cff, 0xff4fa3, 0xff6a2b, 0xe3170a], ground: 0x12082e, rimCool: 0x4d8aff, rimWarm: 0xff4a1a)\n\n        private static func components(_ hex: UInt32) -> SIMD3<Float> {\n            SIMD3(Float((hex >> 16) & 0xff), Float((hex >> 8) & 0xff), Float(hex & 0xff)) / 255\n        }\n\n        private static func components(_ color: Color) -> SIMD3<Float> {\n            let r = color.resolve(in: EnvironmentValues())\n            return SIMD3(r.red, r.green, r.blue)\n        }\n    }\n\n    /// The 24 colour components as one animatable vector, so a palette change crossfades.\n    private struct WaveColors: VectorArithmetic {\n        var v: [Double]\n\n        static var zero: Self { Self(v: Array(repeating: 0, count: 24)) }\n        init(v: [Double]) { self.v = v }\n        init(_ palette: Palette) { v = palette.rgb.flatMap { [Double($0.x), Double($0.y), Double($0.z)] } }\n\n        static func + (a: Self, b: Self) -> Self { Self(v: zip(a.v, b.v).map(+)) }\n        static func - (a: Self, b: Self) -> Self { Self(v: zip(a.v, b.v).map(-)) }\n        mutating func scale(by rhs: Double) { v = v.map { $0 * rhs } }\n        var magnitudeSquared: Double { v.reduce(0) { $0 + $1 * $1 } }\n\n        func argument(_ i: Int) -> Shader.Argument {\n            .float3(Float(v[i * 3]), Float(v[i * 3 + 1]), Float(v[i * 3 + 2]))\n        }\n    }\n\n    /// The shader surface. The clock is relative to first appearance because\n    /// `timeIntervalSinceReferenceDate` overflows `Float` precision and freezes the kernel.\n    /// Only the wave moves: no scale, offset or pulse is ever applied to the sphere.\n    private struct WaveSurface: View, Animatable {\n        var colors: WaveColors\n\n        @Environment(\\.accessibilityReduceMotion) private var reduceMotion\n        @State private var start = Date()\n\n        init(palette: Palette) { colors = WaveColors(palette) }\n\n        nonisolated var animatableData: WaveColors {\n            get { colors }\n            set { colors = newValue }\n        }\n\n        var body: some View {\n            let c = colors\n            TimelineView(.animation(paused: reduceMotion)) { context in\n                let t = Float(context.date.timeIntervalSince(start))\n                Circle()\n                    .fill(.black)\n                    .colorEffect(ShaderLibrary.assistantOrbSiriWave(\n                        .boundingRect, .float(t),\n                        c.argument(0), c.argument(1), c.argument(2), c.argument(3),\n                        c.argument(4), c.argument(5), c.argument(6), c.argument(7)\n                    ))\n            }\n            .aspectRatio(1, contentMode: .fit)\n        }\n    }\n}\n\n#Preview {\n    VStack(spacing: 20) {\n        AssistantOrb(size: 180, palette: .siri)\n        Text(\"Thinking\")\n            .font(.subheadline.weight(.medium))\n            .foregroundStyle(.secondary)\n    }\n    .padding(32)\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n    .background(.black)\n}\n"}],"preview":{},"docs":"https://swiftpieces.com/docs/components/ai/assistant-orb","liquidGlass":false,"metal":true}