Skip to content
C.W.K.
Lesson 03 of 05 · published

The Voice Screen as a Second Child

~16 min · swiftui, composition, voice-mode, ui-wording

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

The same screen, a little polymorphic

By the small hours of 2026-11-03 the v2 face had its own tiles, its own ring around my picture, its own pulse and its own captions. All of them already existed in the Pippa app's voice mode, tuned through a whole trip abroad. At 02:07 KST Dad said what that meant: "보이스 모드 듣는부분만의 문제가 아니라니까. UI/UX 가 거의 동일해. 조금 다형적일 뿐이야" (It isn't only the listening part. The UI/UX is nearly the same, just a little polymorphic). Voice mode is the root class, the Pippa app is its first child, and Cordelia is its second.

Track 7 follows the audio half of that ruling. This lesson is the screen. The voice screen's pieces moved unchanged from the Pippa app into the kit's CwkKitVoiceUI, and at 04:25 the same morning the face stopped drawing its own versions and composed the root's. The first block below is the root's own statement of the contract: the view bodies are the Pippa app's own, moved here unchanged, and only what each host knows comes in from outside (the face's image, the step a reply waits on, the actions), and a host composes the pieces "and never edits them to fit".

What comes from the root, what stays mine

From the root (CwkKitVoiceUI)The body's own
VoiceFace: breath, lean, nod, the ring and its glowthe picture inside it: my own set, at 80%, drifting on black
VoiceFaceRing colors: teal while listening, accent while I speakthe state line's words: the wake phrase and the conversation's countdown
LevelBars, VoiceCaptions, VoiceSendNowButton, VoicePauseButtonCancel and End conversation
VoiceStageLine, VoiceOutputMenu, VoiceImageCardthe CAMERA ON band, the camera preview, On Air, Wrap up, Ear text

The second block shows the seam. portrait(in:) hands VoiceFace a closure that returns Image(CordeliaFace.asset(for: faceState)), the set from the last lesson. The root draws my face alive around whatever picture the host gives it. One detail shows a child adapting without editing its parent: VoiceFace pads itself 28 points on each side, so the face widens its frame by 56 points and I still cover 80% of the screen.

The architecture document still calls the top row "the root's outdoor tiles". Since 2026-11-12 that is not quite what the code does. The kit's OutdoorTile is fixed at 60 points tall and shrinks a one-line title to half size, and the face has to honor every text size, so it draws an app-owned FaceControlTile and native toggles while keeping the root's models, palette and rules. docs/KIT-ADOPTION.md records that as debt to pay back once the kit has a multiline tile. Lesson 4 is the night that forced it.

Here is that screen on build 28, on the iPad and the iPhone that are my bodies, soon after the build reached them through TestFlight on 2026-11-13. Read it against the table. The Pause button and the "Voice here" picker are the root's. My picture at 80%, the state line's words, the tiles and the switches are the body's. So are the two lines under the picker: which brain answers, and how Oriel last reached the body (over Wi-Fi here, not the cable). The "Close eyes" band at the foot is the red eye, pinned outside the scroll (lesson 4). Its eye is hollow because no camera in the house is open.

Cordelia's face on Pippa's iPad in landscape, build 28. Five tiles across the top read Wake off, Think high, Lightning, Noisy and Settings. Under them: Eyes: Auto, then Wake phrase off · tap her face to talk. Pippa's red-haired portrait fills the middle with a Pause button over it. Five switches on the left read Off air, Camera, Ear text, Cut-in and Captions, all off. Over the lower part of the portrait: Voice here, Cordelia answers with Claude · high, Oriel asked over Wi-Fi POST /face 200 · 0 s ago, and Cordelia v2.0.0 (28). A wide Close eyes button runs along the bottom.
Build 28 on Pippa's iPad. The footer says Oriel reached this body over Wi-Fi, and Close eyes sits in the band that never scrolls (lesson 4).
Cordelia's face on Pippa's iPhone in portrait, build 28. Five tiles across the top: Wake off, Think high, Lightning, Noisy and Settings, every title but Noisy wrapped onto two lines. Below them: Eyes: Auto and the state line Wake phrase off · tap her face to talk. Pippa's portrait fills the screen behind a Pause button and five switches, Off air, Camera, Ear text, Cut-in and Captions, all off. Then Voice here, Cordelia answers with Claude · high, Oriel asked over Wi-Fi GET /status 200 · 1 s ago, Cordelia v2.0.0 (28), and a wide Close eyes button at the bottom.
The same build on Pippa's iPhone. The tile titles wrap onto two lines instead of shrinking, which is why the face draws its own FaceControlTile. This time Oriel's last call was GET /status.

Think never says "auto"

At 04:26 Dad saw Cordelia's tile reading "Think: auto" beside voice mode's "Think high" and ruled: "세상 쓸모없는 UI 알림이 auto야" (The most useless thing a UI can tell you is "auto"). Sixteen minutes later the tile spoke the Pippa app's words. The third block is ThinkPlan in CordeliaKit: the tile is "Think high", "Think fixed" for a brain with no effort dial, or "Think ?" when the plan cannot be read, and the line is "Claude · high" or "Lightning · Kimi · low".

The plan is resolved the way my door in cwkPippa will resolve the turn: Lightning's own brain and level if Lightning is on, otherwise the face's own pick or the conversation's brain (named by the door's voice block), at the face's level or Dad's voice-mode level. The footer then says it in a sentence, "Cordelia answers with Claude · high". After a turn, Oriel relays what my answer actually ran on, and if that differs the footer says "Last turn ran on … · next: …". The menu offers brains from cwkPippa's registry as Oriel read it, never a list of the app's own, and when Oriel has not sent them it says exactly that. Nothing on the face says "default" or "Pippa chooses". A value that cannot be read is shown as unread, with the reason in one line.

A state line in voice mode's words

Until the morning of 2026-11-03 the face read "Pippa is thinking" for the whole wait. At 04:21 Dad asked for more: "보이스 모드 수준으로 뭘 하는지 꼼꼼히 보여주라니까" (Show what it is doing, in detail, at voice mode's level). Voice mode had learned that lesson weeks earlier, when one vague "Thinking" covered transcription, upload, memory search, the model's silence and the voice being made.

So while a turn is with me, the line is the root's VoiceStageLine over the kit's TurnStageStatus, with the Pippa app's words exactly: "Saved, sending", "Sending to" and the engine machine's name, then each step Oriel relays from the engine ("Searching memory", "Using web_search", "Thinking", "Writing the answer"), then "Getting her voice ready" until my first sound. If the step cannot be read, the line turns orange and says "Cannot read Pippa's step:" with the reason, never a comforting "thinking". When my voice is going to another device, it says where and that this body listens again when I end there. The rest of the time it is the body's own words: "Waiting for “Cordelia Pippa”", or "In conversation · just talk · 0:42 left".

Code

cwkSiblingKit, CwkKit/Sources/CwkKitVoiceUI/VoiceStage.swift: the root's contract with every host (verbatim, lines 1-10)·swift
// Voice mode's face and controls: the root every host's voice screen inherits.
//
// Moved unchanged from native Pippa's voice screen (PippaMobile `VoiceFaceView`, tuned
// through the Spain trip, cwkPippa docs/PIPPA-VOICE-MODE.md §7) when Dad made the whole
// voice mode the root class (cwkCordelia todo #820, 2026-11-03 02:07: "보이스 모드
// 듣는부분만의 문제가 아니라니까. UI/UX 가 거의 동일해. 조금 다형적일 뿐이야"). The
// bodies are the app's own; only what each host knows comes in from outside: the face's
// image (the Pippa app draws her emotion or an On Air frame; Cordelia draws its own set),
// the step a reply is waiting on, the actions. A host composes these into its screen and
// never edits them to fit (Dad, 02:09: a new Apple host joins as a new child).
cwkCordelia, clients/apple/CordeliaMobile/Sources/Views/FaceView.swift: the second child hands the root its own picture (verbatim, lines 238-260)·swift
  /// Her frame fills `fill` of the screen each way, centred, so the minute's
  /// drift moves a whole picture inside the margin instead of sliding one
  /// that runs off the edges. The face is the root's (`VoiceFace`: she
  /// breathes, leans in with his voice, nods as his words land, the ring and
  /// glow say listening and speaking); the picture is Cordelia's own set, and
  /// the frame is widened by the root's 28 pt margins so she still fills 80%.
  private func portrait(in size: CGSize) -> some View {
    let width = size.width * fill
    let height = size.height * fill
    return VoiceFace(
      ring: VoiceFaceRing(phase: ears.stagePhase, speaking: model.speaking, level: ears.level),
      hearingWords: !ears.hearing.isEmpty, heardPieces: ears.heardPieces, motion: 1,
      aspectRatio: width / max(height, 1)
    ) { _, _, _ in
      Image(CordeliaFace.asset(for: faceState))
        .resizable()
        .scaledToFill()
        .accessibilityHidden(true)
    }
    .frame(width: width + 56, height: height)
    .contentShape(Rectangle())
    .accessibilityLabel("Pippa")
  }
cwkCordelia, CordeliaKit/Sources/CordeliaKit/Ears.swift: ThinkPlan, the tile and the line that never say auto (verbatim, lines 405-440)·swift
/// The brain and level the next turn runs with, as the face shows it: the
/// root's Think tile ("Think high", the Pippa app's wording) and one line
/// ("Claude · high").
public struct ThinkPlan: Sendable, Equatable {
  /// The brain's name, nil when it cannot be read.
  public var brain: String?
  /// The level, nil for a brain with no dial (or when it cannot be read).
  public var effort: String?
  public var lightning: Bool
  /// Why the plan cannot be read, in one line; nil when it can.
  public var unread: String?

  public init(brain: String?, effort: String?, lightning: Bool = false, unread: String? = nil) {
    self.brain = brain
    self.effort = effort
    self.lightning = lightning
    self.unread = unread
  }

  public static func unread(_ why: String) -> ThinkPlan { ThinkPlan(brain: nil, effort: nil, unread: why) }

  /// The Think tile: "Think high", "Think fixed" for a brain with no dial,
  /// "Think ?" when it cannot be read.
  public var tile: String {
    if unread != nil { return "Think ?" }
    return "Think \(effort ?? "fixed")"
  }

  /// "Claude · high", "Lightning · Kimi · low", "GLM · fixed level", or why
  /// it cannot be read.
  public var line: String {
    if let unread { return "Brain and level not read: \(unread)" }
    let name = brain ?? "?"
    let pair = "\(name) · \(effort ?? "fixed level")"
    return lightning ? "Lightning · \(pair)" : pair
  }

External links

Exercise

Find two screens in your own apps that do nearly the same job, such as a phone and a tablet player, or a main app and a widget. Move the shared views into one package where each one takes only what differs from outside (an image, a status, an action closure). Then audit every label that can show a vague word like auto, default, loading or thinking, and replace each with the resolved value or with the exact reason it cannot be read.
Hint
Write the resolution function first, as a pure value type with a tile string and a line string, and unit-test it. A status that cannot be known should be a separate case carrying its reason, not a fallback word.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.