From 51fe02eadbf0e1f78deeb9fd2797e5c6333b1328 Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Thu, 7 May 2026 00:07:46 +0200 Subject: [PATCH] Ch01 UX audit: clean up the screen pollution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three concrete pollution issues found by sampling testbed frames: 1. The composing slot landed on top of the previously-accepted vertex on Aaron's lane (Aaron's α at x=360 vs slot starting at x=324). Even worse for Ben's composing — slot crowded Ben's own cast circle. 2. The in-flight envelope drew at progress=0 directly on top of the just-sealed accepted vertex on the sender's lane. For ~1s of every flight you couldn't tell which was which. 3. The open-envelope card sat next to the recipient's lane and ran into the adjacent lane (e.g. Ben's card extended y=157..297, covering both Aaron's lane area and parts of Carl's). Fixes: - Composing and open-envelope share ONE fixed top-center slot (`detailSlotRect`). They're mutually exclusive on the timeline, so reusing the same slot is honest. A short colored dashed connector runs from the slot to the in-focus cast member's circle so the viewer knows who is writing/reading. - In-flight envelopes are drawn on a "courier track" 36pt above the lane axis. The track has a faint dashed path between sender and recipient; the envelope glides along it; a small drop-line from the envelope toward the lane keeps the spatial cue intact. The accepted-on-sender's-lane vertex stays clean. - Removed the bottom-left footer (was overlapping GlassNarration in the live app). Replaced with a tiny beat-id tag in the top-right corner so PNG sweeps can still be matched to a specific beat for debugging. Bundled, harness 55/55 invariants, 0 audit errors. Ready to propagate the pattern to other chapters with these layout constraints baked in. Co-Authored-By: Claude Opus 4.7 --- .../CrisisViz/Chapters/Ch02_Graph.swift | 226 +++++++++++------- 1 file changed, 133 insertions(+), 93 deletions(-) diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift index e5c555f..2bb842d 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift @@ -336,88 +336,115 @@ struct Ch02_Graph: View { } } - // MARK: - Composing slot + // MARK: - Top-center "current detail" slot + // + // Composing and open-envelope content both render in a SINGLE fixed + // slot at the top center of the canvas — never adjacent to a cast + // circle. Reasons: + // - Lane content (cast circles, accepted vertices, parent edges) + // stays uncluttered. Adjacent-lane pollution disappears. + // - Composing and reading are mutually exclusive events on the + // timeline (one author writes; one recipient reads). Sharing + // one slot is honest about that. + // - A short colored connector ties the slot to whichever cast + // member is "in focus" right now, so the viewer knows who. + + private static let detailSlotY: CGFloat = 16 + private static let detailSlotHeight: CGFloat = 130 private func drawComposingSlot( in context: inout GraphicsContext, size: CGSize, composing: Ch01WorldState.ComposingState ) { guard let msg = Ch01Timeline.messages[composing.messageId] else { return } - let boxW: CGFloat = 320 - let boxH: CGFloat = 110 let authorPos = castPosition(cast: composing.author, size: size) - // Position the slot to the side of the author's lane, on the - // half of the canvas that has more room. Always vertically - // centered on the author's lane Y so the box and cast circle - // read as part of the same gesture. - let placeRight = authorPos.x < size.width / 2 - let boxX: CGFloat = placeRight - ? authorPos.x + 56 - : authorPos.x - 56 - boxW - let boxY: CGFloat = authorPos.y - boxH / 2 - let boxRect = CGRect(x: boxX, y: boxY, width: boxW, height: boxH) - let color = castColor(composing.author) - context.fill(RoundedRectangle(cornerRadius: 10).path(in: boxRect), - with: .color(.black.opacity(0.85))) - context.stroke(RoundedRectangle(cornerRadius: 10).path(in: boxRect), - with: .color(color.opacity(0.95)), lineWidth: 1.5) + let boxRect = detailSlotRect(size: size) + drawDetailSlotChrome(in: &context, rect: boxRect, accent: color, + connectTo: authorPos) context.draw( - Text("✎ \(composing.author.role.displayName.uppercased()) WRITING") - .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + Text("✎ \(composing.author.role.displayName.uppercased()) WRITING \(composing.messageId)") + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) .foregroundColor(color), - at: CGPoint(x: boxRect.minX + 12, y: boxRect.minY + 12), + at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 14), anchor: .leading ) - var rowY = boxRect.minY + 30 - // payload line + var rowY = boxRect.minY + 36 if composing.payloadFilled { context.draw( Text("payload: \(msg.payload)") - .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.85)), - at: CGPoint(x: boxRect.minX + 12, y: rowY), + .font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.88)), + at: CGPoint(x: boxRect.minX + 14, y: rowY), anchor: .leading ) - rowY += 16 + rowY += 18 } - // parents line if composing.parentsFilled { let parentsText = msg.parents.isEmpty ? "(genesis)" : msg.parents.joined(separator: ", ") context.draw( Text("parents: \(parentsText)") - .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.85)), - at: CGPoint(x: boxRect.minX + 12, y: rowY), + .font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.88)), + at: CGPoint(x: boxRect.minX + 14, y: rowY), anchor: .leading ) - rowY += 16 + rowY += 18 } - // PoW progress / hash line if composing.sealed { context.draw( Text("hash: \(msg.hashShort)… ✓") - .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) .foregroundColor(color.opacity(0.95)), - at: CGPoint(x: boxRect.minX + 12, y: rowY), + at: CGPoint(x: boxRect.minX + 14, y: rowY), anchor: .leading ) } else if composing.powProgress > 0 { - let bars = Int(composing.powProgress * 20) + let bars = Int(composing.powProgress * 24) let bar = String(repeating: "█", count: bars) - + String(repeating: "·", count: 20 - bars) + + String(repeating: "·", count: 24 - bars) context.draw( Text("PoW: [\(bar)]") - .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.75)), - at: CGPoint(x: boxRect.minX + 12, y: rowY), + .font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.78)), + at: CGPoint(x: boxRect.minX + 14, y: rowY), anchor: .leading ) } } + /// Slot rectangle — fixed at top-center, fixed size. Sized to fit ~520pt + /// wide, which holds the longest beat content cleanly. + private func detailSlotRect(size: CGSize) -> CGRect { + let boxW: CGFloat = min(540, size.width - 80) + return CGRect( + x: size.width / 2 - boxW / 2, + y: Self.detailSlotY, + width: boxW, + height: Self.detailSlotHeight + ) + } + + /// Common slot frame: rounded box + dashed connector down to the + /// in-focus cast member. + private func drawDetailSlotChrome( + in context: inout GraphicsContext, rect: CGRect, accent: Color, + connectTo target: CGPoint + ) { + var connector = Path() + connector.move(to: CGPoint(x: rect.midX, y: rect.maxY)) + connector.addLine(to: CGPoint(x: target.x, y: target.y - 36)) + context.stroke(connector, + with: .color(accent.opacity(0.45)), + style: StrokeStyle(lineWidth: 1.4, dash: [3, 4])) + context.fill(RoundedRectangle(cornerRadius: 10).path(in: rect), + with: .color(.black.opacity(0.88))) + context.stroke(RoundedRectangle(cornerRadius: 10).path(in: rect), + with: .color(accent.opacity(0.95)), lineWidth: 1.5) + } + // MARK: - Decide arrow private func drawDecideArrow( @@ -450,26 +477,35 @@ struct Ch02_Graph: View { in context: inout GraphicsContext, size: CGSize, flight: Ch01WorldState.InFlightState ) { - let from = castPosition(cast: flight.from, size: size) - let to = castPosition(cast: flight.to, size: size) - // Path + // The flight is drawn ABOVE the lane axis (a "courier track") + // so the in-flight envelope is visually distinct from the + // sender's accepted-on-lane vertex. The track arcs over the + // direct line between sender and recipient. + let lift: CGFloat = 36 + let fromAnchor = castPosition(cast: flight.from, size: size) + let toAnchor = castPosition(cast: flight.to, size: size) + let fromTrack = CGPoint(x: fromAnchor.x, y: fromAnchor.y - lift) + let toTrack = CGPoint(x: toAnchor.x, y: toAnchor.y - lift) + + // Faint dashed path showing the courier track var path = Path() - path.move(to: from) - path.addLine(to: to) + path.move(to: fromTrack) + path.addLine(to: toTrack) context.stroke(path, - with: .color(castColor(flight.from).opacity(0.30)), + with: .color(castColor(flight.from).opacity(0.22)), style: StrokeStyle(lineWidth: 1.0, dash: [3, 5])) - // Envelope at progress + + // Envelope at progress along the track let p = CGFloat(flight.progress) - let pos = CGPoint(x: from.x + (to.x - from.x) * p, - y: from.y + (to.y - from.y) * p) + let pos = CGPoint(x: fromTrack.x + (toTrack.x - fromTrack.x) * p, + y: fromTrack.y + (toTrack.y - fromTrack.y) * p) guard let msg = Ch01Timeline.messages[flight.messageId] else { return } - let envW: CGFloat = 76 - let envH: CGFloat = 32 + let envW: CGFloat = 78 + let envH: CGFloat = 30 let rect = CGRect(x: pos.x - envW / 2, y: pos.y - envH / 2, width: envW, height: envH) context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), - with: .color(castColor(flight.from).opacity(0.92))) + with: .color(castColor(flight.from).opacity(0.95))) context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), with: .color(.white.opacity(0.7)), lineWidth: 1.0) context.draw( @@ -478,6 +514,16 @@ struct Ch02_Graph: View { .foregroundColor(.white), at: pos ) + + // Small drop-line from the envelope down to the courier track + // anchor, so the eye can read the envelope as ABOVE the lane + // rather than floating freely. + var drop = Path() + drop.move(to: CGPoint(x: pos.x, y: pos.y + envH / 2)) + drop.addLine(to: CGPoint(x: pos.x, y: pos.y + envH / 2 + 8)) + context.stroke(drop, + with: .color(castColor(flight.from).opacity(0.45)), + lineWidth: 1.0) } // MARK: - Open envelope card @@ -488,87 +534,81 @@ struct Ch02_Graph: View { ) { guard let msg = Ch01Timeline.messages[env.messageId] else { return } let recipientPos = castPosition(cast: env.recipient, size: size) - let cardW: CGFloat = 320 - let cardH: CGFloat = 140 - // Place to the side of the recipient that has space; if recipient - // is on the right, put card to the left, and vice versa. - let placeRight = recipientPos.x < size.width / 2 - let cardX: CGFloat = placeRight - ? recipientPos.x + 56 - : recipientPos.x - 56 - cardW - let cardY: CGFloat = recipientPos.y - cardH / 2 - let cardRect = CGRect(x: cardX, y: cardY, width: cardW, height: cardH) + // Same slot the composing box uses — open-envelope and composing + // never co-occur on the timeline. + let rect = detailSlotRect(size: size) let color = castColor(msg.author) + drawDetailSlotChrome(in: &context, rect: rect, accent: color, + connectTo: recipientPos) - context.fill(RoundedRectangle(cornerRadius: 10).path(in: cardRect), - with: .color(.black.opacity(0.88))) - context.stroke(RoundedRectangle(cornerRadius: 10).path(in: cardRect), - with: .color(color.opacity(0.95)), lineWidth: 1.5) context.draw( - Text("\(env.recipient.role.displayName.uppercased()) READS \(env.messageId)") - .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + Text("\(env.recipient.role.displayName.uppercased()) READS \(env.messageId) (from \(msg.author.role.displayName.uppercased()))") + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) .foregroundColor(color), - at: CGPoint(x: cardRect.minX + 12, y: cardRect.minY + 14), + at: CGPoint(x: rect.minX + 14, y: rect.minY + 14), anchor: .leading ) - var rowY = cardRect.minY + 32 + var rowY = rect.minY + 36 if env.bodyRevealed { context.draw( Text("body: \(msg.payload)") - .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.85)), - at: CGPoint(x: cardRect.minX + 12, y: rowY), + .font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.88)), + at: CGPoint(x: rect.minX + 14, y: rowY), anchor: .leading ) - rowY += 16 + rowY += 18 } if env.parentsRevealed { let parentsText = msg.parents.isEmpty ? "(genesis)" : msg.parents.joined(separator: ", ") context.draw( Text("parents: \(parentsText)") - .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.85)), - at: CGPoint(x: cardRect.minX + 12, y: rowY), + .font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.88)), + at: CGPoint(x: rect.minX + 14, y: rowY), anchor: .leading ) - rowY += 16 + rowY += 18 } if !env.resolvedParents.isEmpty { let resolved = env.resolvedParents.sorted().joined(separator: ", ") context.draw( - Text("resolved: \(resolved) ✓ (found in local view)") - .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) - .foregroundColor(.green.opacity(0.85)), - at: CGPoint(x: cardRect.minX + 12, y: rowY), + Text("resolved: \(resolved) ✓ (found in \(env.recipient.role.displayName.uppercased())'s local view)") + .font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced)) + .foregroundColor(.green.opacity(0.88)), + at: CGPoint(x: rect.minX + 14, y: rowY), anchor: .leading ) - rowY += 16 + rowY += 18 } if env.verified { context.draw( Text("hash: \(msg.hashShort)… ✓ (verified)") - .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) .foregroundColor(.green.opacity(0.95)), - at: CGPoint(x: cardRect.minX + 12, y: rowY), + at: CGPoint(x: rect.minX + 14, y: rowY), anchor: .leading ) } } - // MARK: - Footer + // MARK: - Beat tag (dev/testbed only) + /// Small beat-id tag in the very top-right corner. The live app + /// already exposes timeline position via the chapter scrubber, so + /// this exists mainly so PNG sweeps can be matched to a specific + /// beat when debugging. Kept tiny and faint. private func drawFooter( in context: inout GraphicsContext, size: CGSize, t: Double, world: Ch01WorldState ) { - let total = Ch01Timeline.totalDuration + guard let beatId = world.activeBeat?.id else { return } context.draw( - Text(String(format: "t=%.1fs / %.0fs · beat: %@", - t, total, world.activeBeat?.id ?? "—")) - .font(.system(size: settings.scaled(9), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.30)), - at: CGPoint(x: 24, y: size.height - 14), - anchor: .leading + Text(beatId) + .font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.20)), + at: CGPoint(x: size.width - 14, y: 10), + anchor: .trailing ) } }