little things, from the people you love
a charm bracelet for your phone · design engineering case study
I designed and engineered an iOS keepsake app where friends give each other hand-painted charms. They collect in a trinket dish, up to three are worn on a bracelet strung across the Dynamic Island, and each one can be passed on.
The product constraint is that you cannot make a bijou for yourself — every object came from a person and carries their note. The engineering consequence is that almost every interesting problem is a systems problem: peer-to-peer transfer between two phones, a render target that isn't your app's process, physical simulation on a 60 Hz budget, and a data model that has to survive schema evolution across builds already on strangers' phones.
This case study is written from the design-engineer seat: the design decision and its implementation are the same decision, made once. Where a visual choice was really a constraint imposed by a framework — or vice versa — that's the interesting part, so it's called out.
I made four surfaces share one model. Everything downstream of [Charm]
is a projection of it.
[Charm] encoded to UserDefaults on every
mutation. The app is fully functional with no account, no network, no iCloud. Sync is an
enhancement layer, never a dependency.
SwiftUI. The dish and the worn strand are two filtered views of the same array, joined by
matchedGeometryEffect so a charm animates between them as one object.
ActivityKit pushes a ContentState to a widget extension that renders
in a separate sandbox — no shared memory, no motion, no timers.
CloudKit private DB mirrors the collection; the public DB is a mailbox for gifts. Peer transfer is a direct device-to-device path that never touches either.
A charm's "worn" state is a boolean on the charm rather than a separate collection. That single choice removes an entire class of bug — a charm cannot be in the dish and on the bracelet simultaneously, cannot be orphaned by a failed move, and cannot desynchronise from the island — because there is no move operation. Wearing is a field toggle, and every surface recomputes.
var wornObjects: [Charm] { myCharms.filter(\.isDynamicIslandEnabled) }
var dishObjects: [Charm] { myCharms.filter { !$0.isDynamicIslandEnabled } }
// one array, two projections — the "move" between them is a toggle + re-render
I built the system as three inks, one typeface, and a small set of view modifiers that encode the house style so it can't drift.
#060606#E0261C#F3EFE7#C69B6BNear-black rather than #000, with a fixed grain
overlay, so the surface reads as paper rather than an OLED void. Camel is reserved almost entirely
for the bracelet thread — a semantic colour, not a decorative one: if you see gold, something is
being worn.
Rather than a stylesheet of values, the system is expressed as typed primitives — a button style, a text scale, an art view. A new screen composes them and inherits the house style for free; there is no path to a one-off font size.
// one art primitive — every charm everywhere goes through it
struct CharmArt: View {
let type: CharmType
var size: CGFloat = 84
var body: some View {
Image(type.imageName)
.resizable().interpolation(.high).scaledToFit()
.frame(width: size, height: size * (110.0 / 120.0)) // fixed aspect
.shadow(color: CharmTheme.charmGlow.opacity(0.35), radius: 5)
}
}
Type is Quicksand with custom .tightText() / .tightDisplay()
modifiers that apply optical tracking per size band — the kind of correction a design system usually
loses in handoff, kept here because the person specifying it is the person writing it.
The detail screen originally used SF Symbols for its actions. Next to hand-drawn charms they read as a different product — a seam between "designed" and "default." Replacing them meant extending the asset pipeline rather than picking a different symbol, which is exactly the kind of fix that only happens when design and build are the same job.
Three variants for iOS's icon modes. The first draft failed on the platform's own geometry: the thread crossed the corner-radius mask and got clipped, and the heart's point sat too close to the bottom edge. Fixed by moving the thread endpoints inside the safe zone, deepening the sag, and giving the heart a 21% bottom margin.
I built four art directions before one survived. My final two are code, not handwork — art is compiled, so it's reproducible and cheap to revise.
The hand-drawn wobble is a displacement filter applied at author time and baked into the PNG, not computed at runtime. Runtime wobble would cost GPU work on every frame and, worse, would look different at every size. Baking makes imperfection a static asset property.
Source illustrations arrive as painted objects on solid black. Naive thresholding destroys the painted edge, so alpha is derived from luminance and the colour is then unpremultiplied:
r, g, b = px[x, y]
a = max(r, g, b) # black matte → luminance is alpha
if a == 0:
out[x, y] = (0, 0, 0, 0)
else: # unpremultiply, or edges go muddy
out[x, y] = (r * 255 // a, g * 255 // a, b * 255 // a, a)
This preserves soft brush edges and anti-aliasing that a colour-key would have hard-clipped — the difference between a sticker and a painted object.
The set is deliberately closed: no custom art, no emoji, no photos. A fixed vocabulary keeps every bijou legible at 22 px on the Dynamic Island and guarantees any future surface can render any charm without new assets.
The background music is synthesised, not licensed: a slow jazz waltz generated with numpy
(Rhodes-ish EP, upright bass, convolution-ish reverb tail) and encoded to AAC with
afconvert. Playback uses the .ambient category with
mixWithOthers, and is skipped entirely when
isOtherAudioPlaying — the app never interrupts something the user chose.
I didn't animate the worn charms — I simulated them. A damped pendulum per charm, driven by the device's real gravity vector.
Each charm's physical parameters are derived from the same table that describes its art, so a heavy object (the cat, the coffee cup) swings slower and wider than a light one (the star). Design intent and simulation constants are the same data.
// gravity → rest angle, clamped so the strand never inverts
let target = atan2(g.x, -g.y).clamped(to: -0.6...0.6)
// stiffness derived from the art's own sway timing — heavier art, slower spring
let k = 20 + 14 * (5.0 - sway.duration)
// underdamped spring (ζ ≈ 0.42) + angular kick from device rotation
vel += (-k * (angle - target) - 2 * 0.42 * sqrt(k) * vel) * dt
vel += -rotationRate.z * 0.9
angle += vel * dt
A single CADisplayLink integrates every pendulum and publishes a
dictionary of angles. Three worn charms cost one timer, not three.
A low-amplitude sine breath is summed onto the solved angle, so a charm at rest on a motionless desk still looks alive.
PendulumOrSway falls back to a canned keyframe animation when
device motion is unavailable, and Reduce Motion disables the sway entirely.
.rotationEffect(anchor: .top) — the charm swings from its bail, not
its centre, which is the whole difference between jewellery and a spinning sticker.
Every charm touch fires a soft impact at 0.9 intensity — on tap and at the moment a drag picks one up. Contact during a transfer uses a CoreHaptics pattern: a six-event rising ramp into a heavy continuous thud, mirroring Apple's own hand-off feel so the gesture reads as system-level.
I treated the Dynamic Island as the product's signature surface, and it's the most constrained target in the system.
A Live Activity is not a view your app draws. It's a ContentState you
hand to the OS, rendered by a widget extension in a separate sandbox. That imposes hard limits which
shaped the design:
Canvas. The home screen's gold thread is drawn with an
immediate-mode Canvas — unavailable in WidgetKit. The identical curve had to be re-expressed as a
Shape so both surfaces share one visual language via two implementations.// same wobble recipe as the app's Canvas strand, as a widget-safe Shape
struct IslandSquiggle: Shape {
func path(in rect: CGRect) -> Path {
// sample a quadratic bezier, then displace each point along its normal
let w = sin(arc * 0.16) * 2.1 + sin(arc * 0.31 + 1.7) * 1.1
p.addLine(to: CGPoint(x: x + nx * w, y: y + ny * w))
}
}
Adding charm names to the island meant changing a state struct that older builds already running on real phones were encoding. Making the new field non-optional would have broken decode for every in-flight activity:
struct ContentState: Codable, Hashable {
var wornTypes: [String]
var wornNames: [String]? // optional — activities from older builds still decode
}
The renderer then zips names against types and falls back to the charm's display name when the array is absent — forward-compatible by construction.
A user reported phantom hearts on their island: stale activities from a previous install, plus relic data from an earlier app under the same bundle ID. The fix treats the OS as the unreliable party — the manager reconciles every activity of its type rather than tracking one handle, adopting the first and ending the rest, and the loader purges charms whose type no longer exists.
expanded island — the Shape-based thread, charms hanging with their given names
I fixed two bugs by making the bad state unrepresentable rather than by detecting and correcting it.
Trinkets in the dish overlapped and read as broken. The usual fixes — collision detection, force-directed packing, jitter-and-retry — all fail non-deterministically as count rises, and all require a frame of "wrong" before they correct.
Instead the dish has 12 fixed slots in normalised ellipse space, ordered so a sparse dish still looks scattered rather than clumped. Charm size is then derived from the geometry:
static let slotCount = 12
// size from the tightest slot pair — overlap is arithmetically impossible
let size: CGFloat = min(60, max(34, Self.minSlotDistance(pixelSlots) * 0.92))
The invariant then propagated upward into the product: since the dish has exactly twelve places, twelve became the capacity, and a full dish is now the mechanic that forces you to pass a bijou on. A layout constraint became the economy.
Long names clipped under a worn charm on the island — a surface where you cannot measure text at render time. Rather than shrink-to-fit forever, the constraint moved upstream to the input:
static let maxNameLength = 26 // fits 3-up on the island at minScaleFactor 0.6
Enforced at compose time, so the render path never has to handle the failing case.
The gold thread appears in five places: draped across the app's home screen, in the home-screen widget, in the lock-screen widget, on the expanded Dynamic Island, and — most recently — in the app icon. It has to look drawn by the same hand in all five, at widths from 160 to 1024 points.
The line is a quadratic Bézier with a perpendicular wobble. The naïve version measures that wobble against the curve's parameter, which stretches the waves with the canvas: the same recipe gives a lazy hand-drawn line at 380pt and a taut zigzag at 160pt. Measuring against arc length instead fixes the wavelength in points, so the wobble is the same physical size wherever it is drawn:
let nx = -dy / seg, ny = dx / seg // perpendicular
arc += seg // distance travelled, not t
let w = sin(arc * 0.16) * 2.1 + sin(arc * 0.31 + 1.7) * 1.1
Two harmonics, because one produces a sine wave and two produce something that reads as a hand.
The app draws this in a Canvas; WidgetKit has none, so the widgets
re-express the identical recipe as a Shape — the constants are copied
deliberately, and commented as the reason.
The icon is the exception that proves the rule. It stretches wavelength and amplitude together by a constant, because an icon is read at 60 points: at true scale its ten cycles crimp into a ripple, and it stops looking hand-drawn and starts looking like crimped wire.
The first widget stacked a thread above a row of charms — the two elements never touched, and it read as a rule over a row of icons rather than as jewellery. The charms had to hang from the line, which means the layout has to know where the line actually is:
// the base curve's y at a horizontal fraction — deliberately without
// the wobble: a charm hangs from the thread's path, not from its jitter
func y(atFraction t: CGFloat, in size: CGSize) -> CGFloat {
size.height * topFraction + 2 * (1 - t) * t * controlDrop
}
Each bijou is offset to that height at its own x, with a jump ring bridging the gap — so the middle charm hangs lowest, following the sag, exactly as it would on a wrist. The same function positions the ring in the app icon, which is generated rather than drawn: the ring is placed at whatever height the squiggle passes its x, so it can never float free of the line it hangs on.
Every charm in the dish opened the last charm's detail sheet. Cause: modifier order.
.contentShape(Rectangle()) applied after .position()
expands the hit area to the whole container, so the topmost view swallows every tap.
CharmArt(...)
.contentShape(Rectangle()) // ← must come BEFORE .position()
.onTapGesture { onTap(charm) }
.position(pos)
Two places where the interface's own conceits — a gesture, and a text field doing double duty — didn't survive contact with VoiceOver, and had to be answered in the model rather than patched in the view.
A bijou's words live in name when someone composes one, but the three
starter bijoux carry theirs in note with an empty name. Sighted users never
notice — each surface reads whichever field it was written to. A screen reader, reading one label for
all of them, announced half the collection as nothing at all.
Worse, a hand-drawn charm keeps a stock charmType underneath its art, so a
hand-drawn chihuahua announced itself as "Heart." The art was the whole gift, and the label
described the leftover scaffolding.
var spokenWords: String { // one reader for both worlds
if !name.isEmpty && !note.isEmpty && name != note { return "\(name), \(note)" }
return name.isEmpty ? (note.isEmpty ? charmType.displayName : note) : name
}
var spokenKind: String { // the art decides, not the stale type
artData == nil ? "\(charmType.displayName.lowercased()) bijou" : "hand-drawn bijou"
}
Putting both on the model rather than in each view means every surface that gains a label later —
widget, island, detail sheet — inherits the correct one for free. The bug was never a missing
accessibilityLabel; it was that no single field held the answer.
Sealing a bijou is a 1.15-second press-and-hold: the haze blooms under your thumb, and only then do you name the word. It is the most deliberately physical moment in the app — and a timed continuous press is exactly what Switch Control and VoiceOver cannot perform.
The tempting fix is to weaken the gesture for everyone, or to add a permanent button beside it and let the ceremony compete with a shortcut. Instead the button exists only when assistive technology is running:
if voiceOver || switchControl {
Button { sealCeremony() } label: { Text("seal it") }
.accessibilityHint("seals it behind the haze — you name the word next")
}
Everyone reaches the same state by the route that suits them, and neither route is a degraded copy of the other.
I built three delivery paths, chosen by what the recipient has — and one rearchitecture driven by research rather than by code.
Room-scale discovery over Bluetooth/Wi-Fi, role-filtered so senders only see receivers. Mutual confirmation before any bytes move.
A Gift record addressed to a normalised handle; the recipient's app
collects and deletes it, bounded by remaining dish capacity.
A record whose recordName is the code, plus a static preview
page and a universal link that auto-claims after install.
Claim codes are fetched by ID rather than queried by field. CloudKit requires an explicit queryable index for field lookups — an operational dependency that fails at exactly the wrong moment. Making the code the primary key removes the index entirely, and deletion after read gives one-time semantics for free:
let id = CKRecord.ID(recordName: "gift-\(normalize(code))")
let rec = try await publicDB.record(for: id) // direct fetch, no index
_ = try? await publicDB.modifyRecords(saving: [], deleting: [id])
Making the magic word the primary key has a consequence. The word a person types and the word their
friend types have to resolve to the same record name across keyboards, autocorrect and accents, so
wordKey folds to Latin, uppercases, and keeps only letters, digits and dashes.
Everything else is discarded before the lookup.
Which means a symbol cannot be part of a secret here. Type our song! and
the ! never reaches the key — two words differing only by punctuation are the
same word. So when these moved from generated codes to words people choose, the strength rule could not
be the usual "add a special character":
// letters, ≥1 digit, ≥6 characters — digits survive the fold, symbols don't
if !key.contains(where: \.isNumber) { return "add a number so no one can guess it" }
The requirement is downstream of the normaliser, not a style choice: a digit is the only extra entropy that survives to the key. Guidance is worded as coaching rather than validation, and the receiving side stays permissive — it folds whatever you type and looks it up, so being generous to the person entering a word costs nothing while the strength lives where the word is made.
CKContainer(identifier:) does not return nil or throw when the binary
lacks the iCloud entitlement — it crashes the process. Since simulator builds are
compiled with signing disabled, container creation had to become lazy and gated behind a crash-safe
probe:
private var cloudCapable: Bool { FileManager.default.ubiquityIdentityToken != nil }
private func containerIfAvailable() -> CKContainer? {
guard cloudCapable else { return nil } // probe before touching CKContainer
...
}
Touch-to-give was unreliable enough to feel broken. Rather than tune it, the question became how a shipped product at scale solves the same problem. Pokémon GO's trade flow turns out to have no proximity magic at all: a 100 m GPS check, a pre-existing friendship, both players tapping Confirm, then a long unhurried ceremony.
Bijoux was doing the inverse — straining to detect an intimate physical moment while never modelling the human one. The audit of the transport layer then found four defects that tuning would never have fixed:
role was written into
discovery info and never read, so two senders would connect to each other.sendCharm() directly — any UWB error
transferred the charm to whoever happened to be connected, with no touch at all.UIDevice.current.name, not the user's Bijoux username.
giver — tap a name to offer
receiver — accept or declineI shipped 31 builds with no CI, and one rule I set for myself eliminated a whole category of phantom bug.
# sources live in iCloud Drive, which breaks xcodebuild's working directory
rsync -a --delete "$SRC/Charms/" "$DST/Charms/"
# simulator builds skip signing entirely — hence the CKContainer guard above
xcodebuild -scheme Charms -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build
A build once shipped where the simulator and TestFlight disagreed, and the resulting bug
looked supernatural. Now both are updated together and
CFBundleVersion is asserted equal on both before anything is called done.
An earlier app shared this bundle ID. The loader drops charms whose type no longer exists, clamps the worn count to the cap, and removes the relics from iCloud.
Every visual change is verified by driving the simulator and capturing the actual screen — the test is the artefact, and it doubles as the case-study asset.
The project uses filesystem-synchronised groups, so files shared with the widget target are declared as membership exceptions rather than duplicated build entries.
Current state, build 31. Click to enlarge.
Every significant decision I made: what prompted it, what I decided, and how I implemented. Filter by area.
The bugs that taught me the most, and what I changed structurally because of each.
A tester opened the app to two hearts already on their Dynamic Island and an error saying the
bracelet was full. Three causes compounded: relic records from an earlier app under the same bundle
ID, unknown charm types falling through to a .heart default, and stale Live
Activities from a previous install that the app had no handle to.
A Python heredoc used to apply a copy sweep hit a syntax error from an unescaped apostrophe. The script died, the pipeline continued, and a build shipped and was verified — with none of the edits in it.
The widgets were set in SF Rounded while the app is set in Quicksand — the bracelet on the home
screen didn't look like the bracelet in the app. The fix is to add the font to the widget target.
Xcode's synchronised folders let one target borrow files from another's folder via
membershipExceptions, so the font went in that list beside the shared Swift
sources. It built clean. The font never reached the widget.
Those exception sets carry sources, not resources: the compiler was satisfied,
the copy phase was never told. Nothing failed, and Font.custom falls back to
the system face in silence — so the only symptom was a widget that looked very slightly wrong.
ls inside the .appex before believing
the font shipped. A resource must live in its own target's folder. This is the same lesson as the
copy sweep below, arriving from the opposite direction: there, a failed edit produced a clean build;
here, a clean build produced a missing file.A local export, run only to verify an archive, uploaded itself to App Store Connect and consumed the next build number. The following real upload was then rejected as a duplicate, and a build nobody intended to distribute sat in the list looking exactly as legitimate as the others.
Cause: manageAppVersionAndBuildNumber defaults to true in
exportArchive. The default quietly rewrites the version of the thing you
asked it to package.
<key>manageAppVersionAndBuildNumber</key>
<false/> <!-- or the export renumbers the build behind you -->
ExportOptions.plist, and the stale build number is recorded in the log as
never-distribute. A default that mutates release identity is worth reading the docs for once, in
advance, rather than discovering from a rejection.Partway through a working session the OS revoked read access to the iCloud source directory — existing files could no longer be read, overwritten, or deleted, though new files could be created. The working copy and the canonical copy silently diverged.
The peer path has only been exercised simulator-to-simulator, which shares one machine's radios. Two devices on separate Bluetooth stacks is a materially harder case and the next real test.
Both sides confirm, but the two phones don't yet play a single animation at the same instant. The Pokémon GO lesson isn't fully cashed in until both people are watching the same thing simultaneously — which needs a shared clock across the peer session.
Charms swinging on the island itself is currently impossible — widget extensions have no motion access and no animation loop. Documented as a platform limit rather than faked.
This is the engineering read — architecture, pipelines, invariants and failure modes. The same project told as a product design story covers the art direction, the copy system, the onboarding ceremony and the research behind the give flow.