diff --git a/Sources/KeyPathAppKit/Models/LogicalKeymap.swift b/Sources/KeyPathAppKit/Models/LogicalKeymap.swift index 69623d6a2..d138ee8a5 100644 --- a/Sources/KeyPathAppKit/Models/LogicalKeymap.swift +++ b/Sources/KeyPathAppKit/Models/LogicalKeymap.swift @@ -8,6 +8,27 @@ struct LogicalKeymap: Identifiable { let iconFilename: String // SVG filename in Resources/Keymaps (without extension) let coreLabels: [UInt16: String] // 30-key letter block (always applied) let extraLabels: [UInt16: String] // Number row + outer punctuation (toggle) + let shiftLabels: [UInt16: String] // Shifted legends when the keymap defines them + + init( + id: String, + name: String, + description: String, + learnMoreURL: URL, + iconFilename: String, + coreLabels: [UInt16: String], + extraLabels: [UInt16: String], + shiftLabels: [UInt16: String] = [:] + ) { + self.id = id + self.name = name + self.description = description + self.learnMoreURL = learnMoreURL + self.iconFilename = iconFilename + self.coreLabels = coreLabels + self.extraLabels = extraLabels + self.shiftLabels = shiftLabels + } func label(for keyCode: UInt16, includeExtraKeys: Bool) -> String? { if let label = coreLabels[keyCode] { @@ -93,7 +114,8 @@ struct LogicalKeymap: Identifiable { learnMoreURL: URL(string: "https://support.apple.com/guide/mac-help/change-input-sources-mchlp1406/mac")!, iconFilename: "QWERTY", // Fallback icon; UI uses globe SF Symbol instead coreLabels: labels, - extraLabels: [:] + extraLabels: [:], + shiftLabels: provider.currentShiftLabels ) } @@ -108,7 +130,22 @@ struct LogicalKeymap: Identifiable { home: ["a", "s", "d", "f", "g", "h", "j", "k", "l", ";"], bottom: ["z", "x", "c", "v", "b", "n", "m", ",", ".", "/"] ), - extraLabels: [:] + extraLabels: [:], + shiftLabels: [ + KeyCode.grave: "~", + KeyCode.one: "!", + KeyCode.two: "@", + KeyCode.three: "#", + KeyCode.four: "$", + KeyCode.five: "%", + KeyCode.six: "^", + KeyCode.seven: "&", + KeyCode.eight: "*", + KeyCode.nine: "(", + KeyCode.zero: ")", + KeyCode.minus: "_", + KeyCode.equal: "+", + ] ) /// AZERTY - French keyboard layout @@ -278,6 +315,16 @@ struct LogicalKeymap: Identifiable { private enum KeyCode { static let grave: UInt16 = 50 + static let one: UInt16 = 18 + static let two: UInt16 = 19 + static let three: UInt16 = 20 + static let four: UInt16 = 21 + static let five: UInt16 = 23 + static let six: UInt16 = 22 + static let seven: UInt16 = 26 + static let eight: UInt16 = 28 + static let nine: UInt16 = 25 + static let zero: UInt16 = 29 static let minus: UInt16 = 27 static let equal: UInt16 = 24 static let leftBracket: UInt16 = 33 diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageEntrance.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageEntrance.swift index 9cc479b40..d232d3c6a 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageEntrance.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageEntrance.swift @@ -46,8 +46,15 @@ struct KeyboardStageCinematicLighting: Equatable, Sendable { ) } + /// The front's travel is eased, not linear: the keyboard occupies the + /// trailing ~58% of the window, so a linear sweep crosses every keycap in + /// the first ~0.28 s of the reveal and spends the rest on flat copy. The + /// ease-in keeps roughly 60% of the duration on the keyboard span. + static let frontTravelExponent: Float = 1.9 + static func frontX(for entrance: KeyboardStageEntranceFrame) -> Float { - frontStart + (frontEnd - frontStart) * entrance.progress + let eased = pow(entrance.progress, frontTravelExponent) + return frontStart + (frontEnd - frontStart) * eased } static func exposure( @@ -248,7 +255,7 @@ struct KeyboardStageEntrancePresentation: Equatable, Sendable { } static let holdDuration: TimeInterval = 1.50 - static let transitionDuration: TimeInterval = 0.55 + static let transitionDuration: TimeInterval = 0.75 static let reducedMotionTransitionDuration: TimeInterval = 0.25 private(set) var phase = Phase.pending diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStagePalette.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStagePalette.swift index c568ea2e5..e28076c3f 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStagePalette.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStagePalette.swift @@ -194,43 +194,43 @@ struct KeyboardStagePalette: Equatable, Sendable { ) case .recommended: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.875, 0.91, 0.929), + fill: KeyboardStageRGBA(0.910, 0.918, 0.922), accent: KeyboardStageRGBA(0.439, 0.561, 0.631), glow: KeyboardStageRGBA(0.263, 0.655, 0.902), - legend: KeyboardStageRGBA(0.337, 0.416, 0.467), - borderStrength: 0.52 + legend: KeyboardStageRGBA(0.285, 0.337, 0.369), + borderStrength: 0.62 ) case .escape: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.835, 0.882, 0.918), + fill: KeyboardStageRGBA(0.900, 0.914, 0.922), accent: KeyboardStageRGBA(0.38, 0.50, 0.57), glow: KeyboardStageRGBA(0.263, 0.655, 0.902), - legend: KeyboardStageRGBA(0.337, 0.416, 0.467), - borderStrength: 0.68 + legend: KeyboardStageRGBA(0.268, 0.326, 0.360), + borderStrength: 0.78 ) case .hyper: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.855, 0.89, 0.929), + fill: KeyboardStageRGBA(0.906, 0.914, 0.925), accent: KeyboardStageRGBA(0.455, 0.549, 0.663), glow: KeyboardStageRGBA(0.302, 0.518, 0.847), - legend: KeyboardStageRGBA(0.306, 0.376, 0.463), - borderStrength: 0.56 + legend: KeyboardStageRGBA(0.278, 0.326, 0.386), + borderStrength: 0.68 ) case .launcher: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.855, 0.914, 0.925), + fill: KeyboardStageRGBA(0.905, 0.921, 0.923), accent: KeyboardStageRGBA(0.404, 0.60, 0.647), glow: KeyboardStageRGBA(0.263, 0.655, 0.902), - legend: KeyboardStageRGBA(0.251, 0.373, 0.396), - borderStrength: 0.52 + legend: KeyboardStageRGBA(0.251, 0.345, 0.365), + borderStrength: 0.64 ) case .installed: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.863, 0.91, 0.925), + fill: KeyboardStageRGBA(0.906, 0.918, 0.923), accent: KeyboardStageRGBA(0.404, 0.576, 0.647), glow: KeyboardStageRGBA(0.263, 0.655, 0.902), - legend: KeyboardStageRGBA(0.278, 0.376, 0.416), - borderStrength: 0.48 + legend: KeyboardStageRGBA(0.265, 0.345, 0.377), + borderStrength: 0.60 ) case .dimmed: KeyboardStageSurfaceStyle( @@ -267,38 +267,38 @@ struct KeyboardStagePalette: Equatable, Sendable { ) case .recommended: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.15, 0.31, 0.54), + fill: KeyboardStageRGBA(0.115, 0.145, 0.190), accent: KeyboardStageRGBA(0.34, 0.66, 1), legend: KeyboardStageRGBA(0.94, 0.98, 1), - borderStrength: 0.52 + borderStrength: 0.68 ) case .escape: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.12, 0.37, 0.66), + fill: KeyboardStageRGBA(0.095, 0.145, 0.205), accent: KeyboardStageRGBA(0.39, 0.76, 1), legend: KeyboardStageRGBA(0.96, 0.99, 1), - borderStrength: 0.58 + borderStrength: 0.82 ) case .hyper: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.35, 0.23, 0.61), + fill: KeyboardStageRGBA(0.135, 0.125, 0.195), accent: KeyboardStageRGBA(0.70, 0.55, 1), legend: KeyboardStageRGBA(0.98, 0.97, 1), - borderStrength: 0.62 + borderStrength: 0.76 ) case .launcher: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.12, 0.43, 0.45), + fill: KeyboardStageRGBA(0.095, 0.165, 0.175), accent: KeyboardStageRGBA(0.32, 0.83, 0.79), legend: KeyboardStageRGBA(0.94, 1, 0.99), - borderStrength: 0.58 + borderStrength: 0.72 ) case .installed: KeyboardStageSurfaceStyle( - fill: KeyboardStageRGBA(0.15, 0.39, 0.30), + fill: KeyboardStageRGBA(0.105, 0.165, 0.145), accent: KeyboardStageRGBA(0.38, 0.81, 0.59), legend: KeyboardStageRGBA(0.95, 1, 0.97), - borderStrength: 0.5 + borderStrength: 0.66 ) case .dimmed: KeyboardStageSurfaceStyle( diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageProjection.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageProjection.swift index 24c712fd3..c46dd7b3e 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageProjection.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageProjection.swift @@ -9,14 +9,22 @@ struct KeyboardStageProjection: Equatable, Sendable { let destinationHeight = Float(max(1, size.height)) let aspectRatio = destinationWidth / destinationHeight let bounds = scene.layoutBounds - let zoom = max(0.1, scene.viewport.zoom) + let horizontalZoom = max(0.1, scene.viewport.zoom) + // The keyboard hero is intentionally wider than it is tall. Above 1x, + // only the horizontal camera moves closer; the vertical stays at 1 so + // the whole board — top edge through bottom apron — remains in frame. + // A vertical crop makes the keys read as sliding off the deck. + let verticalZoom = min(1, horizontalZoom) - var visibleHeight = min(bounds.size.height, bounds.size.height / zoom) + var visibleHeight = min(bounds.size.height, bounds.size.height / verticalZoom) var visibleWidth = visibleHeight * aspectRatio if visibleWidth > bounds.size.width { - visibleWidth = min(bounds.size.width, bounds.size.width / zoom) + visibleWidth = min(bounds.size.width, bounds.size.width / verticalZoom) visibleHeight = visibleWidth / aspectRatio } + if horizontalZoom > verticalZoom { + visibleWidth *= verticalZoom / horizontalZoom + } visibleWidth = min(bounds.size.width, max(0.1, visibleWidth)) visibleHeight = min(bounds.size.height, max(0.1, visibleHeight)) diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageScene.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageScene.swift index 1e3fcf617..885708bc1 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageScene.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageScene.swift @@ -198,22 +198,35 @@ enum KeyboardStageRevealTarget: Equatable, Sendable { // MARK: - Scene elements +/// Horizontal placement of a legend within its keycap. MacBook hardware sets +/// word legends on wide edge keys toward the outer edge (`tab`, `caps lock`, +/// and left `shift` leading; `delete`, `return`, and right `shift` trailing) +/// while letters, symbols, and stacked legends stay centered. +enum KeyboardStageLegendAlignment: Equatable, Sendable { + case leading + case center + case trailing +} + struct KeyboardStageLegend: Equatable, Sendable { var primary: String var previous: String? var secondary: String? var transitionProgress: Float + var alignment: KeyboardStageLegendAlignment init( primary: String, previous: String? = nil, secondary: String? = nil, - transitionProgress: Float = 1 + transitionProgress: Float = 1, + alignment: KeyboardStageLegendAlignment = .center ) { self.primary = primary self.previous = previous self.secondary = secondary self.transitionProgress = transitionProgress + self.alignment = alignment } static func interpolated( @@ -226,7 +239,8 @@ struct KeyboardStageLegend: Equatable, Sendable { primary: end.primary, previous: start.primary, secondary: progress < 0.5 ? start.secondary : end.secondary, - transitionProgress: progress + transitionProgress: progress, + alignment: progress < 0.5 ? start.alignment : end.alignment ) } @@ -238,7 +252,8 @@ struct KeyboardStageLegend: Equatable, Sendable { start.transitionProgress, end.transitionProgress, progress - ) + ), + alignment: end.alignment ) } } diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSceneBuilder.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSceneBuilder.swift index 6eae5af19..009276c3d 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSceneBuilder.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSceneBuilder.swift @@ -104,8 +104,8 @@ enum KeyboardStageSceneBuilder { dim(keys: &keys, except: [keys[capsIndex].id], opacity: 0.86) keys[capsIndex].role = .recommended keys[capsIndex].glow = 0.28 - keys[capsIndex].legend = KeyboardStageLegend(primary: "caps lock") - viewport = capsViewport(capsFrame: keys[capsIndex].frame, bounds: layoutBounds, zoom: 1) + keys[capsIndex].legend = KeyboardStageLegend(primary: "caps lock", alignment: .leading) + viewport = capsViewport(capsFrame: keys[capsIndex].frame, bounds: layoutBounds, zoom: 1.18) } case .capsMotivation: @@ -113,10 +113,10 @@ enum KeyboardStageSceneBuilder { dim(keys: &keys, except: [capsKeyID], opacity: 0.88) keys[capsIndex].role = .recommended keys[capsIndex].glow = 0.72 - keys[capsIndex].legend = KeyboardStageLegend(primary: "caps lock") + keys[capsIndex].legend = KeyboardStageLegend(primary: "caps lock", alignment: .leading) keys[capsIndex].accessibilityRole = .capsToEscape revealTarget = .capsToEscape(keyID: capsKeyID) - viewport = capsViewport(capsFrame: keys[capsIndex].frame, bounds: layoutBounds, zoom: 1) + viewport = capsViewport(capsFrame: keys[capsIndex].frame, bounds: layoutBounds, zoom: 1.18) } case .capsApplying: @@ -136,7 +136,7 @@ enum KeyboardStageSceneBuilder { keys[capsIndex].accessibilityRole = .capsToEscape decorations.append(contentsOf: capsEchoes(frame: keys[capsIndex].frame)) revealTarget = .capsToEscape(keyID: capsKeyID) - viewport = capsViewport(capsFrame: keys[capsIndex].frame, bounds: layoutBounds, zoom: 1) + viewport = capsViewport(capsFrame: keys[capsIndex].frame, bounds: layoutBounds, zoom: 1.18) } case .capsInstalled: @@ -147,7 +147,7 @@ enum KeyboardStageSceneBuilder { keys[capsIndex].legend = KeyboardStageLegend(primary: "esc") keys[capsIndex].accessibilityRole = .capsToEscape revealTarget = .capsToEscape(keyID: capsKeyID) - viewport = capsViewport(capsFrame: keys[capsIndex].frame, bounds: layoutBounds, zoom: 1) + viewport = capsViewport(capsFrame: keys[capsIndex].frame, bounds: layoutBounds, zoom: 1.18) } case .hyperMotivation: @@ -360,12 +360,20 @@ enum KeyboardStageSceneBuilder { keymap: LogicalKeymap, includePunctuation: Bool ) -> [KeyboardStageKey] { - layout.keys.enumerated().compactMap { index, key in - let legend = displayLegend( + let layoutMinX = layout.keys.map(\.visualX).min() ?? 0 + let layoutMaxX = layout.keys.map { $0.visualX + $0.width }.max() ?? 0 + return layout.keys.enumerated().compactMap { index, key in + var legend = displayLegend( for: key, keymap: keymap, includePunctuation: includePunctuation ) + legend.alignment = edgeLegendAlignment( + for: key, + legend: legend, + layoutMinX: layoutMinX, + layoutMaxX: layoutMaxX + ) guard shouldShowInHero(key: key, label: legend.primary) else { return nil } @@ -405,6 +413,30 @@ enum KeyboardStageSceneBuilder { return true } + /// MacBook hardware sets word legends on wide edge keys toward the outer + /// edge of the board; letters, symbols, and stacked legends stay centered. + private static func edgeLegendAlignment( + for key: PhysicalKey, + legend: KeyboardStageLegend, + layoutMinX: Double, + layoutMaxX: Double + ) -> KeyboardStageLegendAlignment { + guard legend.secondary == nil, + legend.primary.count > 1, + key.width >= 1.35 + else { + return .center + } + + if key.visualX <= layoutMinX + 0.05 { + return .leading + } + if key.visualX + key.width >= layoutMaxX - 0.05 { + return .trailing + } + return .center + } + private static func displayLegend( for key: PhysicalKey, keymap: LogicalKeymap, @@ -414,7 +446,7 @@ enum KeyboardStageSceneBuilder { case KeyCode.capsLock: return KeyboardStageLegend(primary: "caps lock") case KeyCode.tab: - return KeyboardStageLegend(primary: "Tab") + return KeyboardStageLegend(primary: "tab") case KeyCode.function: return KeyboardStageLegend(primary: "fn") case KeyCode.leftControl, KeyCode.rightControl: @@ -427,6 +459,15 @@ enum KeyboardStageSceneBuilder { return KeyboardStageLegend(primary: "⌘", secondary: "command") default: let label = keymap.displayLabel(for: key, includeExtraKeys: includePunctuation) + if KeyCode.numberRow.contains(key.keyCode), + let shiftedLabel = keymap.shiftLabels[key.keyCode], + shiftedLabel != label + { + return KeyboardStageLegend( + primary: shiftedLabel, + secondary: label + ) + } return KeyboardStageLegend( primary: label.count == 1 ? label.uppercased() : label.capitalized ) @@ -456,11 +497,14 @@ enum KeyboardStageSceneBuilder { KeyboardStageDecoration( id: "keyboard-deck", kind: .keyboardDeck, + // The reference keyboard rests on a visible aluminum apron below + // the bottom row; a flush bottom edge makes the keys read as if + // they are sliding off the board. frame: KeyboardStageRect( x: bounds.minX - 0.22, y: bounds.minY - 0.24, width: bounds.size.width + 0.44, - height: bounds.size.height + 0.5 + height: bounds.size.height + 0.76 ), rotationRadians: 0, role: .deck, @@ -667,15 +711,32 @@ enum KeyboardStageSceneBuilder { ) -> KeyboardStageViewport { KeyboardStageViewport( focus: KeyboardStagePoint( - x: min(bounds.maxX, capsFrame.midX + bounds.size.width * 0.17), + x: min(bounds.maxX, capsFrame.midX + bounds.size.width * 0.12), y: min(bounds.maxY, capsFrame.midY + bounds.size.height * 0.04) ), zoom: zoom, - verticalBias: 0.08 + // A mild downward bias keeps the aluminum apron below the bottom + // row visible, matching the reference's grounded composition. + verticalBias: 0.005 ) } private enum KeyCode { + static let numberRow: Set = [ + 50, // ` + 18, // 1 + 19, // 2 + 20, // 3 + 21, // 4 + 23, // 5 + 22, // 6 + 26, // 7 + 28, // 8 + 25, // 9 + 29, // 0 + 27, // - + 24, // = + ] static let tab: UInt16 = 48 static let space: UInt16 = 49 static let leftCommand: UInt16 = 55 diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSemanticOverlay.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSemanticOverlay.swift index e4375a99d..d28adfc9a 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSemanticOverlay.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageSemanticOverlay.swift @@ -145,11 +145,18 @@ private struct KeyboardStageKeyLegendOverlay: View { let visibility: Double var body: some View { - let primarySize = max(8, min(22, frame.height * 0.34)) - let secondarySize = max(6, min(12, frame.height * 0.17)) + let usesNumberRowStack = key.legend.primary.count == 1 + && key.legend.secondary?.count == 1 + let primarySize = max( + 8, + min(22, frame.height * (usesNumberRowStack ? 0.30 : 0.34)) + ) + let secondarySize = usesNumberRowStack + ? primarySize + : max(6, min(14, frame.height * 0.20)) let primaryWeight: Font.Weight = switch key.role { - case .standard, .modifier, .dimmed: .regular - case .deck, .recommended, .escape, .hyper, .launcher, .installed: .medium + case .standard, .modifier, .recommended, .escape, .dimmed: .light + case .deck, .hyper, .launcher, .installed: .medium } VStack(spacing: max(1, frame.height * 0.025)) { ZStack { @@ -169,7 +176,11 @@ private struct KeyboardStageKeyLegendOverlay: View { if let secondary = key.legend.secondary { Text(verbatim: secondary) - .font(.system(size: secondarySize, weight: .medium, design: .default)) + .font(.system( + size: secondarySize, + weight: usesNumberRowStack ? .regular : .medium, + design: .default + )) .lineLimit(1) .minimumScaleFactor(0.65) .opacity(0.86) @@ -190,8 +201,17 @@ private struct KeyboardStageKeyLegendOverlay: View { .opacity(Double(lighting.legendGlow) * 0.06), radius: 6.5 ) - .padding(.horizontal, max(2, frame.width * 0.08)) - .frame(width: frame.width, height: frame.height) + .padding( + .horizontal, + key.legend.alignment == .center + ? max(2, frame.width * 0.08) + : max(2, frame.height * 0.16) + ) + .frame( + width: frame.width, + height: frame.height, + alignment: overlayAlignment + ) .rotationEffect(.radians(Double(key.rotationRadians))) .position(x: frame.midX, y: frame.midY) // Metal renders the visible glyph mask so its emissive light can take @@ -214,6 +234,14 @@ private struct KeyboardStageKeyLegendOverlay: View { lighting.legendColor(settledColor: style.legend).color } + private var overlayAlignment: Alignment { + switch key.legend.alignment { + case .leading: .leading + case .center: .center + case .trailing: .trailing + } + } + private var accessibilityLabel: Text { switch key.accessibilityRole { case .capsToEscape: diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal index a8e060dce..1090382bc 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal @@ -214,6 +214,65 @@ static float keypath_hash(float2 point) { return fract(sin(dot(point, float2(12.9898, 78.233))) * 43758.5453); } +static float keypath_value_noise(float2 point) { + float2 cell = floor(point); + float2 fraction = fract(point); + float2 eased = fraction * fraction * (3.0 - 2.0 * fraction); + float lowerLeft = keypath_hash(cell); + float lowerRight = keypath_hash(cell + float2(1.0, 0.0)); + float upperLeft = keypath_hash(cell + float2(0.0, 1.0)); + float upperRight = keypath_hash(cell + float2(1.0, 1.0)); + return mix( + mix(lowerLeft, lowerRight, eased.x), + mix(upperLeft, upperRight, eased.x), + eased.y + ); +} + +static float keypath_area_shadow( + float2 point, + float2 halfSize, + float radius, + float2 castOffset, + float softness +) { + float2 perpendicular = normalize(float2(-castOffset.y, castOffset.x)); + float sampleSpread = max(0.75, softness * 0.34); + constexpr float sampleWeights[5] = { 0.10, 0.23, 0.34, 0.23, 0.10 }; + constexpr float samplePositions[5] = { -1.0, -0.48, 0.0, 0.48, 1.0 }; + float shadow = 0.0; + for (uint index = 0; index < 5; ++index) { + float2 sampleOffset = castOffset + + perpendicular * samplePositions[index] * sampleSpread; + float distance = keypath_rounded_rect_distance( + point - sampleOffset, + halfSize, + radius + ); + shadow += (1.0 - smoothstep(-0.40, softness, distance)) + * sampleWeights[index]; + } + return saturate(shadow); +} + +static float keypath_ggx_distribution(float normalDotHalf, float roughness) { + float alpha = max(0.04, roughness * roughness); + float alphaSquared = alpha * alpha; + float denominator = normalDotHalf * normalDotHalf * (alphaSquared - 1.0) + 1.0; + return alphaSquared / max(0.0001, 3.14159265 * denominator * denominator); +} + +static float keypath_smith_visibility(float normalDotView, float normalDotLight, float roughness) { + float k = pow(roughness + 1.0, 2.0) * 0.125; + float view = normalDotView / max(0.0001, normalDotView * (1.0 - k) + k); + float light = normalDotLight / max(0.0001, normalDotLight * (1.0 - k) + k); + return view * light; +} + +static float3 keypath_fresnel_schlick(float cosine, float3 reflectance) { + return reflectance + (1.0 - reflectance) * pow(1.0 - saturate(cosine), 5.0); +} + static float2 keypath_rounded_rect_gradient( float2 point, float2 halfSize, @@ -251,6 +310,10 @@ fragment float4 keypath_keyboard_stage_fragment( float borderStrength = saturate(input.parameters.w); bool isDeck = input.materialKind < 0.5; bool isKey = input.materialKind >= 0.5 && input.materialKind < 1.5; + // Chips are floating UI affordances (launcher/Rules capsules, tokens), + // not keycaps: flatter edges, a soft top sheen, and an elevated shadow + // instead of a seated key's bevel-and-well treatment. + bool isChip = input.materialKind >= 1.5; float keyPress = isKey ? saturate(input.lighting.w) : 0.0; bool reduceMotion = uniforms.entrance.y > 0.5; float pixelExposure = keypath_cinematic_exposure( @@ -274,15 +337,33 @@ fragment float4 keypath_keyboard_stage_fragment( float antialiasWidth = max(0.75, fwidth(distance)); float surfaceAlpha = 1.0 - smoothstep(-antialiasWidth, antialiasWidth, distance); - // The dark ring immediately around a key is its recessed well. It keeps the - // backlight attached to the hardware instead of reading as a neon outline. + // The keycap floats above a recessed well. These masks are deliberately + // separate: a dark cavity, a tight contact occlusion ring, and the exposed + // strip of the backlight diffuser. Collapsing them into one blur makes the + // keyboard read like flat rounded rectangles instead of physical hardware. float outsideDistance = max(distance, 0.0); - float wellCore = exp(-outsideDistance * 0.58); - float wellTail = exp(-outsideDistance * 0.18); - float wellAlpha = isKey - ? mix(0.90, 0.38, illumination) - * (wellCore * 0.72 + wellTail * 0.28) + // In the dark hold the wells widen and deepen so each cap visibly floats + // above a recessed cavity like the reference photograph; the settled + // light state keeps its original softer geometry. + float wellDistance = keypath_rounded_rect_distance( + input.localPixels, + input.halfSizePixels + float2(isKey ? mix(5.6, 3.6, illumination) : 0.0), + input.cornerRadiusPixels + (isKey ? mix(3.4, 2.2, illumination) : 0.0) + ); + float wellMask = isKey + ? (1.0 - smoothstep(-0.25, mix(2.1, 1.20, illumination), wellDistance)) + * (1.0 - surfaceAlpha) : 0.0; + float contactOcclusion = isKey + ? exp(-outsideDistance * mix(0.55, 0.92, illumination)) * wellMask + : 0.0; + float cavityOcclusion = isKey + ? exp(-outsideDistance * mix(0.165, 0.24, illumination)) * wellMask + : 0.0; + float wellAlpha = saturate( + contactOcclusion * mix(0.96, 0.38, illumination) + + cavityOcclusion * mix(0.52, 0.08, illumination) + ); float skirtDistance = keypath_rounded_rect_distance( input.localPixels - float2( mix(0.45, 0.10, pressure), @@ -294,64 +375,110 @@ fragment float4 keypath_keyboard_stage_fragment( float skirtAlpha = isKey ? (1.0 - smoothstep(-0.35, 2.1, skirtDistance)) * (1.0 - surfaceAlpha) : 0.0; + skirtAlpha *= mix(0.92, 0.52, illumination); - float contactShadowDistance = keypath_rounded_rect_distance( - input.localPixels - float2( - mix(0.60, 0.14, pressure), - mix(3.20, 0.85, pressure) + float contactShadowAlpha = keypath_area_shadow( + input.localPixels, + input.halfSizePixels + float2(isKey ? 0.55 : 0.0), + input.cornerRadiusPixels + (isKey ? 0.35 : 0.0), + float2( + mix(isDeck ? -0.3 : -0.65, -0.08, pressure), + mix(isDeck ? 2.8 : 2.45, 0.58, pressure) ), + isDeck ? 5.6 : 2.65 + ) * (1.0 - surfaceAlpha) * mix(isDeck ? 0.16 : 0.63, 0.10, pressure); + float castShadowAlpha = keypath_area_shadow( + input.localPixels, input.halfSizePixels, - input.cornerRadiusPixels - ); - float contactShadowAlpha = (1.0 - smoothstep(-0.25, 4.8, contactShadowDistance)) - * mix(isDeck ? 0.13 : 0.42, 0.09, pressure); - float softShadowDistance = keypath_rounded_rect_distance( - input.localPixels - float2( - mix(isDeck ? 1.4 : 1.0, 0.2, pressure), - mix(isDeck ? 9.5 : 7.2, 1.8, pressure) + input.cornerRadiusPixels, + float2( + mix(isDeck ? -2.0 : (isChip ? -2.9 : -2.35), -0.35, pressure), + mix(isDeck ? 10.5 : (isChip ? 12.5 : 8.6), 2.0, pressure) ), - input.halfSizePixels, - input.cornerRadiusPixels - ); - float softShadowAlpha = (1.0 - smoothstep(-1.0, isDeck ? 22.0 : 15.0, softShadowDistance)) - * mix(isDeck ? 0.27 : 0.34, 0.11, pressure); - contactShadowAlpha *= mix(1.42, 1.0, illumination); - softShadowAlpha *= mix(1.20, 1.0, illumination); + isDeck ? 18.0 : (isChip ? 16.0 : 10.5) + ) * (1.0 - surfaceAlpha) * mix(isDeck ? 0.26 : (isChip ? 0.44 : 0.34), 0.09, pressure); + if (isChip) { + // Hovering above the deck: almost no tight contact, all soft cast. + contactShadowAlpha *= 0.45; + } + contactShadowAlpha *= mix(1.62, 0.48, illumination); + castShadowAlpha *= mix(1.30, 0.72, illumination); float shadowAlpha = contactShadowAlpha - + softShadowAlpha * (1.0 - contactShadowAlpha); + + castShadowAlpha * (1.0 - contactShadowAlpha); shadowAlpha = saturate(shadowAlpha * shadowStrength); - // Inactive keys retain only a restrained neutral spill. Scene-role glow - // carries the cool emphasis for Caps Lock and other active lesson keys. + // Backlight is an emissive plane under the cap, visible only where the + // raised cap does not occlude its well. It escapes more strongly opposite + // the area light and near corners, avoiding an even neon ring. + float2 normalizedCapPosition = input.localPixels + / max(input.halfSizePixels, float2(1.0)); + float2 radialDirection = normalize(normalizedCapPosition + float2(0.0001)); + float2 incomingLight = normalize(float2(0.88, -0.48)); + float edgeEscape = saturate(0.34 - dot(radialDirection, incomingLight) * 0.66); + float cornerEscape = pow(saturate(max(abs(normalizedCapPosition.x), abs(normalizedCapPosition.y))), 3.0); + float diffuserExposure = wellMask + * saturate(0.010 + edgeEscape * 0.42 + cornerEscape * 0.08); float backlightStrength = isKey - ? mix(0.18, 0.0, illumination) * (0.72 + transientGlow * 0.28) + ? mix(0.10, 0.0, illumination) * (0.76 + transientGlow * 0.24) : 0.0; - float tightBacklight = exp(-outsideDistance * 0.38) * backlightStrength * 0.28; - float broadBacklight = exp(-outsideDistance * 0.095) * backlightStrength * 0.10; - float roleGlowAlpha = exp(-outsideDistance * 0.24) * glow * 0.22; - roleGlowAlpha *= 1.0 - surfaceAlpha; - float pressGlowAlpha = exp(-outsideDistance * 0.31) + float diffuserBacklight = diffuserExposure * backlightStrength * 0.58; + float tightBacklight = exp(-outsideDistance * 0.62) + * diffuserExposure + * backlightStrength + * 0.22; + float broadBacklight = exp(-outsideDistance * 0.14) + * diffuserExposure + * backlightStrength + * 0.045; + float instructionalEmphasis = max( + glow, + saturate((borderStrength - 0.60) * 5.0) + ); + float roleGlowAlpha = exp(-outsideDistance * 0.34) + * diffuserExposure + * instructionalEmphasis + * 0.31; + float roleHaloAlpha = exp(-outsideDistance * 0.12) + * edgeEscape + * instructionalEmphasis + * mix(0.095, 0.024, illumination) + * (1.0 - surfaceAlpha); + roleGlowAlpha = roleGlowAlpha + + roleHaloAlpha * (1.0 - roleGlowAlpha); + float pressGlowAlpha = exp(-outsideDistance * 0.46) + * diffuserExposure * keyPress - * mix(0.28, 0.10, illumination) + * mix(0.24, 0.08, illumination) * step(0.001, glow) * (1.0 - surfaceAlpha); roleGlowAlpha = roleGlowAlpha + pressGlowAlpha * (1.0 - roleGlowAlpha); - float entranceGlowAlpha = tightBacklight + broadBacklight; + float entranceGlowAlpha = diffuserBacklight + tightBacklight + broadBacklight; entranceGlowAlpha *= 1.0 - surfaceAlpha; float glowAlpha = roleGlowAlpha + entranceGlowAlpha * (1.0 - roleGlowAlpha); float bevelWidth = isDeck ? min(8.0, min(input.halfSizePixels.x, input.halfSizePixels.y) * 0.10) - : min(6.0, min(input.halfSizePixels.x, input.halfSizePixels.y) * 0.20); + : (isChip + ? min(3.0, min(input.halfSizePixels.x, input.halfSizePixels.y) * 0.12) + : min(6.0, min(input.halfSizePixels.x, input.halfSizePixels.y) * 0.20)); float bevel = smoothstep(-bevelWidth, -0.20, distance) * surfaceAlpha; float2 distanceGradient = keypath_rounded_rect_gradient( input.localPixels, input.halfSizePixels, input.cornerRadiusPixels ); - float bevelDepth = isDeck ? 0.22 : 0.72; + // The reference reads as one warm area light at the upper right: rims + // catch only on light-facing edges and the far sides fall to black. In + // the dark room every additive edge term follows this mask; the settled + // light keeps the original even response. + float2 rimLightDirection = normalize(float2(0.70, -0.62)); + float edgeFacing = saturate(dot(distanceGradient, rimLightDirection)); + float directionalRim = pow(edgeFacing, 1.7); + float rimDirectionality = mix(directionalRim * 2.2, 1.0, illumination); + float emphasisRimShape = mix(max(directionalRim, 0.30), 1.0, illumination); + float bevelDepth = isDeck ? 0.22 : (isChip ? 0.30 : 0.72); float2 faceCoordinate = clamp( input.localPixels / max(input.halfSizePixels, float2(1.0)), float2(-1.0), @@ -365,6 +492,26 @@ fragment float4 keypath_keyboard_stage_fragment( 1.0 )); + // Two octaves of low-amplitude height noise supply molded-plastic grain + // and brushed aluminum microfacets. Deriving the normal in the shader keeps + // the detail scale stable and avoids a visibly tiled photographic texture. + float2 materialPixels = input.stageUV * uniforms.viewportSize; + float microScale = isDeck ? 0.085 : 0.16; + float2 microCoordinate = materialPixels * microScale; + float microCenter = keypath_value_noise(microCoordinate); + float microX = keypath_value_noise(microCoordinate + float2(0.55, 0.0)) - microCenter; + float microY = keypath_value_noise(microCoordinate + float2(0.0, 0.55)) - microCenter; + float brushedRidge = isDeck + ? sin(materialPixels.y * 1.82 + keypath_value_noise(materialPixels * 0.012) * 4.0) + * 0.0038 + : 0.0; + float microNormalStrength = isDeck ? 0.026 : 0.014; + normal = normalize(normal + float3( + microX * microNormalStrength, + microY * microNormalStrength + brushedRidge, + 0.0 + )); + // A broad source moves right-to-left. Its specular front catches each // bevel before the diffuse exposure arrives, like a product-film area light. float lightFront = uniforms.cinematicLighting.x; @@ -376,7 +523,31 @@ fragment float4 keypath_keyboard_stage_fragment( * sin(saturate(uniforms.entrance.x) * 3.14159265); float3 lightDirection = normalize(float3(0.62, -0.28, 0.74)); float diffuse = saturate(dot(normal, lightDirection)); + float normalDotView = saturate(normal.z); + float normalDotLight = max(0.001, diffuse); float3 halfVector = normalize(lightDirection + float3(0.0, 0.0, 1.0)); + float normalDotHalf = saturate(dot(normal, halfVector)); + float viewDotHalf = saturate(halfVector.z); + float roughnessVariation = keypath_value_noise(materialPixels * (isDeck ? 0.032 : 0.058)) - 0.5; + float roughness = clamp( + (isDeck ? 0.31 : 0.47) + roughnessVariation * (isDeck ? 0.055 : 0.035), + 0.12, + 0.82 + ); + float3 baseReflectance = isDeck + ? float3(0.30, 0.27, 0.25) + : float3(0.032, 0.034, 0.038); + float3 microfacetFresnel = keypath_fresnel_schlick(viewDotHalf, baseReflectance); + float microfacetDistribution = keypath_ggx_distribution(normalDotHalf, roughness); + float microfacetVisibility = keypath_smith_visibility( + normalDotView, + normalDotLight, + roughness + ); + float3 microfacetSpecular = microfacetFresnel + * microfacetDistribution + * microfacetVisibility + / max(0.04, 4.0 * normalDotView * normalDotLight); float specularPower = isDeck ? 88.0 : (isKey ? 42.0 : 34.0); float specular = pow(saturate(dot(normal, halfVector)), specularPower); float bevelSpecular = pow( @@ -394,7 +565,16 @@ fragment float4 keypath_keyboard_stage_fragment( input.localPixels.y / max(1.0, input.halfSizePixels.y) * 0.5 + 0.5 ); surfaceColor *= mix(1.025, 0.94, verticalPosition * (isDeck ? 0.28 : 1.0)); - surfaceColor *= mix(1.0, 0.91, pressure); + // A press tilts the cap away from the area light: the face darkens toward + // its lower edge while the top bevel catches more, so compression stays + // legible even under a saturated accent fill. + surfaceColor *= mix(1.0, mix(1.05, 0.80, verticalPosition), pressure); + float crownHighlight = isKey + ? pow(saturate(1.0 - length(faceCoordinate) * 0.52), 2.2) * faceMask + : 0.0; + float lowerBevelCatch = isKey + ? bevel * smoothstep(0.05, 0.82, distanceGradient.y) + : 0.0; // A physical press remains legible at both cinematic endpoints. In the // dark room it appears as light leaking through the cap material; once the // area light arrives, the settled accent-filled face carries the state. @@ -405,16 +585,39 @@ fragment float4 keypath_keyboard_stage_fragment( ); // Stable, sub-pixel material variation prevents the aluminum deck and - // graphite caps from reading as flat vector fills. + // graphite caps from reading as flat vector fills. The deck receives an + // anisotropic brushed component while the caps retain much finer grain. float materialNoise = keypath_hash(floor(input.stageUV * uniforms.viewportSize * 0.72)) - 0.5; - surfaceColor += materialNoise * (isDeck ? 0.010 : 0.004) * mix(0.30, 1.0, illumination); + float brushedNoise = keypath_hash(float2( + floor(input.stageUV.y * uniforms.viewportSize.y * 1.65), + floor(input.stageUV.x * 37.0) + )) - 0.5; + surfaceColor += ( + materialNoise * (isDeck ? 0.015 : 0.0050) + + brushedNoise * (isDeck ? 0.009 : 0.0) + ) * mix(0.38, 1.0, illumination); float3 neutralLight = mix(float3(0.62, 0.72, 0.86), float3(1.0), illumination); surfaceColor *= mix(0.58, 1.04, illumination) + diffuse * illumination * 0.12; + surfaceColor += microfacetSpecular + * neutralLight + * normalDotLight + * mix(isDeck ? 0.032 : 0.018, isDeck ? 0.22 : 0.11, illumination); + surfaceColor += neutralLight + * crownHighlight + * mix(0.0045, 0.018, illumination); + surfaceColor += neutralLight + * lowerBevelCatch + * rimDirectionality + * mix(0.018, 0.050, illumination); + float pressTopCatch = isKey + ? bevel * smoothstep(0.10, 0.85, -distanceGradient.y) * pressure + : 0.0; + surfaceColor += neutralLight * pressTopCatch * mix(0.10, 0.16, illumination); float broadSpecularStrength = isDeck ? 0.026 : (isKey ? 0.012 : 0.026); surfaceColor += neutralLight * ( specular * (broadSpecularStrength + illumination * 0.13) - + bevelSpecular * (0.085 + illumination * 0.14) + + bevelSpecular * rimDirectionality * (0.035 + illumination * 0.16) ); surfaceColor += float3(1.0, 0.82, 0.64) * grazingBand * (specular * 0.32 + fresnel * 0.08 + bevel * 0.04); @@ -423,9 +626,13 @@ fragment float4 keypath_keyboard_stage_fragment( // keyboard from the right. It is strongest on the deck, catches key // bevels lightly, and vanishes exactly at the settled light endpoint. float entranceDarkness = 1.0 - pixelExposure; - float rightGraze = smoothstep(0.60, 1.03, lightCoordinate) + // The graze reaches further left and pools unevenly across the deck so + // the metal reads as a broad photographic sweep instead of a uniform + // tint; the noise scale is large enough to read as light, not texture. + float rightGraze = smoothstep(0.44, 1.04, lightCoordinate) * entranceDarkness * (1.0 - pixelExposure * 0.75); + float grazePool = 0.55 + 0.90 * keypath_value_noise(materialPixels * 0.004); float3 warmLightDirection = normalize(float3(0.86, -0.18, 0.48)); float warmDiffuse = saturate(dot(normal, warmLightDirection)); float3 warmHalfVector = normalize(warmLightDirection + float3(0.0, 0.0, 1.0)); @@ -434,21 +641,33 @@ fragment float4 keypath_keyboard_stage_fragment( isDeck ? 78.0 : 52.0 ); float warmMaterialResponse = isDeck - ? 0.072 + warmDiffuse * 0.048 - : (isKey ? 0.017 + warmDiffuse * 0.032 : 0.0); + ? 0.19 + warmDiffuse * 0.115 + : (isKey ? 0.040 + warmDiffuse * 0.066 : 0.0); float warmBevelResponse = isDeck - ? bevel * 0.020 + warmSpecular * bevel * 0.032 - : (isKey ? bevel * 0.030 + warmSpecular * bevel * 0.064 : 0.0); + ? bevel * 0.042 + warmSpecular * bevel * 0.070 + : (isKey + ? (bevel * 0.058 + warmSpecular * bevel * 0.118) + * (0.35 + directionalRim * 0.90) + : 0.0); surfaceColor += float3(0.62, 0.43, 0.31) * rightGraze + * grazePool * (warmMaterialResponse + warmBevelResponse); float edge = smoothstep(-2.2, -0.15, distance) * surfaceAlpha; float edgeIllumination = mix(0.10, 0.72, illumination) + grazingBand * 0.56; + // In the dark room the lesson key's rim reads as a white bevel catching + // the light, with the role hue carried by the surrounding halo; the + // settled state returns to the pure accent stroke. + float3 accentEdgeColor = mix( + input.accentColor.rgb, + float3(0.88, 0.94, 1.0), + (1.0 - illumination) * 0.60 + ); surfaceColor = mix( surfaceColor, - input.accentColor.rgb, - edge * borderStrength * 0.48 * edgeIllumination + accentEdgeColor, + edge * borderStrength * 0.48 * edgeIllumination * emphasisRimShape ); surfaceColor += input.glowColor.rgb * edge @@ -459,6 +678,29 @@ fragment float4 keypath_keyboard_stage_fragment( * edge * keyPress * mix(0.54, 0.24, illumination); + float illuminatedBevel = isKey + ? bevel * max( + smoothstep(0.08, 0.78, distanceGradient.y), + smoothstep(0.12, 0.82, -distanceGradient.x) * 0.72 + ) + : 0.0; + float3 illuminatedBevelColor = mix( + input.glowColor.rgb, + float3(0.82, 0.90, 1.0), + 0.72 + ); + surfaceColor += illuminatedBevelColor + * illuminatedBevel + * instructionalEmphasis + * emphasisRimShape + * mix(0.24, 0.065, illumination); + + // A soft highlight along a chip's upper edge reads as light skimming a + // raised glass surface, separating it from the keycaps beneath. + float chipSheen = isChip + ? bevel * smoothstep(0.15, 0.90, -distanceGradient.y) + : 0.0; + surfaceColor += neutralLight * chipSheen * (0.05 + illumination * 0.07); // A narrow internal rim makes the translucent legend light feel seated in // a real cap even though semantic glyphs remain native and accessible. @@ -466,6 +708,7 @@ fragment float4 keypath_keyboard_stage_fragment( float3 innerRimColor = mix(float3(0.22, 0.23, 0.25), input.glowColor.rgb, glow); surfaceColor += innerRimColor * innerRim + * mix(directionalRim + 0.25, 1.0, illumination) * (backlightStrength * 0.075 + keyPress * mix(0.52, 0.20, illumination)); float nonGlowAlpha = 1.0 @@ -544,8 +787,15 @@ fragment float4 keypath_keyboard_legend_fragment( // pass lets the bloom derive from real excess radiance instead of a second // screen-space text shadow. constexpr float3 darkLegendColor = float3(0.7484, 0.7484, 0.7100); + // Apertures answer the same warm area light as the caps: legends near the + // graze sit brighter and slightly warmer, far keys dimmer, so the + // backlight reads as one panel lit from the side, not a uniform sheet. + float lightAxis = saturate((input.stageUV.x - 0.30) / 0.70); + float3 spatialDarkLegend = darkLegendColor + * mix(0.84, 1.05, lightAxis) + * mix(float3(1.0), float3(1.03, 0.99, 0.92), lightAxis); float3 legendColor = mix( - darkLegendColor, + spatialDarkLegend, max(input.settledColor.rgb, float3(0.0)), pixelExposure ); @@ -553,7 +803,8 @@ fragment float4 keypath_keyboard_legend_fragment( float roleEmission = saturate(input.parameters.z); float allowsBloom = saturate(input.parameters.w); float emission = entranceDarkness - * (0.95 + entranceEmission * 0.65 + roleEmission * 0.35) + * mix(0.74, 1.16, lightAxis) + * (1.08 + entranceEmission * 0.78 + roleEmission * 0.42) * allowsBloom; float3 radiance = legendColor * (1.0 + emission); @@ -733,8 +984,8 @@ fragment float4 keypath_keyboard_composite_fragment( bloomEnvelope *= 0.72; } float3 bloomContribution = ( - tightBloom * 0.22 - + bloom * 0.09 + tightBloom * 0.30 + + bloom * 0.12 ) * bloomEnvelope; float bloomPeak = max( bloomContribution.r, diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageLegendAtlas.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageLegendAtlas.swift index 16d09ce5c..4b2fe29f1 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageLegendAtlas.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageLegendAtlas.swift @@ -1,3 +1,4 @@ +import AppKit import CoreGraphics import CoreText import Foundation @@ -5,22 +6,32 @@ import Metal struct KeyboardStageLegendAtlasDescriptor: Hashable, Sendable { enum Weight: Int, Hashable, Sendable { + case light case regular case medium } + enum Alignment: Int, Hashable, Sendable { + case leading + case center + case trailing + } + var primary: String var secondary: String? var weight: Weight + var alignment: Alignment init( primary: String, secondary: String? = nil, - weight: Weight = .regular + weight: Weight = .regular, + alignment: Alignment = .center ) { self.primary = primary self.secondary = secondary self.weight = weight + self.alignment = alignment } } @@ -72,7 +83,10 @@ struct KeyboardStageLegendAtlasSignature: Hashable, Sendable { break } } - return lhs.weight.rawValue < rhs.weight.rawValue + if lhs.weight != rhs.weight { + return lhs.weight.rawValue < rhs.weight.rawValue + } + return lhs.alignment.rawValue < rhs.alignment.rawValue } } @@ -228,7 +242,8 @@ final class KeyboardStageLegendAtlasCache: @unchecked Sendable { enum KeyboardStageLegendAtlasRasterizer { private static let primaryFontSize = CGFloat(KeyboardStageLegendAtlasLayout.cellHeight) * 0.33 - private static let secondaryFontSize = CGFloat(KeyboardStageLegendAtlasLayout.cellHeight) * 0.16 + private static let secondaryFontSize = CGFloat(KeyboardStageLegendAtlasLayout.cellHeight) * 0.22 + private static let numberRowFontSize = CGFloat(KeyboardStageLegendAtlasLayout.cellHeight) * 0.30 private static let color = CGColor(gray: 1, alpha: 1) static func rasterize( @@ -295,16 +310,29 @@ enum KeyboardStageLegendAtlasRasterizer { ) if let secondary = descriptor.secondary, !secondary.isEmpty { + let usesNumberRowStack = descriptor.primary.count == 1 && secondary.count == 1 drawLine( descriptor.primary, - font: font(size: primaryFontSize, weight: descriptor.weight), - center: CGPoint(x: cell.midX, y: cell.minY + cell.height * 0.62), + font: font( + size: usesNumberRowStack ? numberRowFontSize : primaryFontSize, + weight: descriptor.weight + ), + center: CGPoint( + x: cell.midX, + y: cell.minY + cell.height * (usesNumberRowStack ? 0.68 : 0.62) + ), in: context ) drawLine( secondary, - font: font(size: secondaryFontSize, weight: descriptor.weight), - center: CGPoint(x: cell.midX, y: cell.minY + cell.height * 0.27), + font: font( + size: usesNumberRowStack ? numberRowFontSize : secondaryFontSize, + weight: descriptor.weight + ), + center: CGPoint( + x: cell.midX, + y: cell.minY + cell.height * (usesNumberRowStack ? 0.32 : 0.27) + ), in: context ) } else { @@ -312,15 +340,24 @@ enum KeyboardStageLegendAtlasRasterizer { descriptor.primary, font: font(size: primaryFontSize, weight: descriptor.weight), center: CGPoint(x: cell.midX, y: cell.midY), + alignment: descriptor.alignment, + cell: cell, in: context ) } } + /// Horizontal padding inside a cell for edge-aligned legends. The renderer + /// anchors the quad's matching edge to the keycap's inner margin, so this + /// stays small and fixed rather than proportional. + static let edgeAlignedCellPadding: CGFloat = 6 + private static func drawLine( _ string: String, font: CTFont, center: CGPoint, + alignment: KeyboardStageLegendAtlasDescriptor.Alignment = .center, + cell: CGRect? = nil, in context: CGContext ) { let attributes: [NSAttributedString.Key: Any] = [ @@ -331,8 +368,16 @@ enum KeyboardStageLegendAtlasRasterizer { NSAttributedString(string: string, attributes: attributes) ) let bounds = CTLineGetBoundsWithOptions(line, [.useGlyphPathBounds]) + let x: CGFloat = switch (alignment, cell) { + case let (.leading, .some(cell)): + cell.minX + edgeAlignedCellPadding - bounds.minX + case let (.trailing, .some(cell)): + cell.maxX - edgeAlignedCellPadding - bounds.maxX + default: + center.x - bounds.midX + } context.textPosition = CGPoint( - x: center.x - bounds.midX, + x: x, y: center.y - bounds.midY ) CTLineDraw(line, context) @@ -342,20 +387,12 @@ enum KeyboardStageLegendAtlasRasterizer { size: CGFloat, weight: KeyboardStageLegendAtlasDescriptor.Weight ) -> CTFont { - let baseFont = CTFontCreateUIFontForLanguage(.system, size, nil) - ?? CTFontCreateWithName("Helvetica" as CFString, size, nil) - guard weight == .medium else { return baseFont } - - let traits: [CFString: Any] = [ - kCTFontWeightTrait: 0.23 - ] - let attributes: [CFString: Any] = [ - kCTFontTraitsAttribute: traits - ] - let descriptor = CTFontDescriptorCreateCopyWithAttributes( - CTFontCopyFontDescriptor(baseFont), - attributes as CFDictionary - ) - return CTFontCreateWithFontDescriptor(descriptor, size, nil) + let appKitWeight: NSFont.Weight = switch weight { + case .light: .light + case .regular: .regular + case .medium: .medium + } + let systemFont = NSFont.systemFont(ofSize: size, weight: appKitWeight) + return CTFontCreateWithName(systemFont.fontName as CFString, size, nil) } } diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageMetalRenderer.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageMetalRenderer.swift index 36de4a03d..13e3db900 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageMetalRenderer.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageMetalRenderer.swift @@ -48,6 +48,7 @@ private struct KeyboardStageLegendDrawItem { let opacity: Float let entranceEmission: Float let roleEmission: Float + let alignment: KeyboardStageLegendAtlasDescriptor.Alignment } final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Sendable { @@ -508,7 +509,7 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se items.reserveCapacity(scene.keys.count * 2) for key in scene.keys { - let keyFrame = projection.projectKey(key) + let keyFrame = materialKeyFrame(projection.projectKey(key)) let visibility = legendVisibility( keyFrame: keyFrame, scene: scene, @@ -519,6 +520,7 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se guard baseOpacity > 0.001 else { continue } let weight = legendWeight(for: key.role) + let alignment = atlasAlignment(for: key.legend.alignment) let settledColor = palette.style( for: key.role, interactionLevel: key.interactionLevel @@ -532,13 +534,15 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se descriptor: KeyboardStageLegendAtlasDescriptor( primary: previous, secondary: key.legend.secondary, - weight: weight + weight: weight, + alignment: alignment ), frame: keyFrame, settledColor: settledColor, opacity: baseOpacity * (1 - progress), entranceEmission: lighting.legendGlow, - roleEmission: max(key.glow, key.interactionLevel) + roleEmission: max(key.glow, key.interactionLevel), + alignment: alignment )) } @@ -547,13 +551,15 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se descriptor: KeyboardStageLegendAtlasDescriptor( primary: key.legend.primary, secondary: key.legend.secondary, - weight: weight + weight: weight, + alignment: alignment ), frame: keyFrame, settledColor: settledColor, opacity: baseOpacity * (key.legend.previous == nil ? 1 : progress), entranceEmission: lighting.legendGlow, - roleEmission: max(key.glow, key.interactionLevel) + roleEmission: max(key.glow, key.interactionLevel), + alignment: alignment )) } } @@ -578,6 +584,7 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se return KeyboardStageGPULegendInstance( geometry: legendGeometry( keyFrame: item.frame, + alignment: item.alignment, drawableSize: drawableSize ), uvRect: atlasRect, @@ -595,17 +602,35 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se private func legendGeometry( keyFrame: CGRect, + alignment: KeyboardStageLegendAtlasDescriptor.Alignment, drawableSize: CGSize ) -> SIMD4 { let width = max(1, Float(drawableSize.width)) let height = max(1, Float(drawableSize.height)) - // The native baseline used 34% of the cap height. A slightly smaller - // 28% optical size is closer to a real MacBook aperture and leaves the - // bloom room to breathe without making every legend look bold. + // The atlas includes its own vertical padding. This resolves regular + // legends to roughly 28% of the cap height, matching the proportions + // of the MacBook legends in the onboarding reference. let legendHeight = max(1, Float(keyFrame.height) * 0.84) let legendWidth = legendHeight * 2 + // Edge-aligned atlas cells draw the glyphs flush to a small fixed cell + // padding, so anchoring the quad's matching edge at the keycap's inner + // margin puts the text where MacBook hardware prints it. + let cellPadding = legendWidth + * Float( + KeyboardStageLegendAtlasRasterizer.edgeAlignedCellPadding + / CGFloat(KeyboardStageLegendAtlasLayout.cellWidth) + ) + let edgeInset = Float(keyFrame.height) * 0.16 + let centerX = switch alignment { + case .leading: + Float(keyFrame.minX) + edgeInset - cellPadding + legendWidth / 2 + case .center: + Float(keyFrame.midX) + case .trailing: + Float(keyFrame.maxX) - edgeInset + cellPadding - legendWidth / 2 + } return SIMD4( - Float(keyFrame.midX) / width * 2 - 1, + centerX / width * 2 - 1, 1 - Float(keyFrame.midY) / height * 2, legendWidth / width * 2, legendHeight / height * 2 @@ -616,13 +641,23 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se for role: KeyboardStageKeyRole ) -> KeyboardStageLegendAtlasDescriptor.Weight { switch role { - case .standard, .modifier, .dimmed: - .regular - case .deck, .recommended, .escape, .hyper, .launcher, .installed: + case .standard, .modifier, .recommended, .escape, .dimmed: + .light + case .deck, .hyper, .launcher, .installed: .medium } } + private func atlasAlignment( + for alignment: KeyboardStageLegendAlignment + ) -> KeyboardStageLegendAtlasDescriptor.Alignment { + switch alignment { + case .leading: .leading + case .center: .center + case .trailing: .trailing + } + } + private func legendVisibility( keyFrame: CGRect, scene: KeyboardStageScene, @@ -686,7 +721,7 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se instances.append(contentsOf: scene.keys.map { key in let visibleGlow = scene.displayMode.reduceTransparency ? 0 : key.glow return makeInstance( - frame: projection.projectKey(key), + frame: materialKeyFrame(projection.projectKey(key)), rotationRadians: key.rotationRadians, style: palette.style( for: key.role, @@ -718,6 +753,12 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se return instances } + private func materialKeyFrame(_ frame: CGRect) -> CGRect { + let horizontalInset = min(3.2, frame.width * 0.045) + let verticalInset = min(3.0, frame.height * 0.045) + return frame.insetBy(dx: horizontalInset, dy: verticalInset) + } + private func makeDecorationInstance( _ decoration: KeyboardStageDecoration, projection: KeyboardStageProjection, @@ -729,7 +770,9 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se case .keyboardDeck: 0.14 case .applicationTarget, .launcherChoiceTarget, .handoffTarget: - 0.34 + // Full capsule: these are floating UI chips above the deck, not + // keycaps, and their silhouette is the first thing that says so. + 0.50 case .capsEcho: 0.31 case .modifierToken: @@ -784,7 +827,7 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se } else if materialKind < 1.5 { 24 + max(glow, lighting.transientGlow) * 14 } else { - 18 + max(glow, lighting.transientGlow) * 12 + 30 + max(glow, lighting.transientGlow) * 12 } let centerX = Float(frame.midX) / width * 2 - 1 let centerY = 1 - Float(frame.midY) / height * 2 diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/SwiftUIKeyboardStageView.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/SwiftUIKeyboardStageView.swift index 0629b6659..80327819c 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/SwiftUIKeyboardStageView.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/SwiftUIKeyboardStageView.swift @@ -213,7 +213,8 @@ private struct KeyboardStageSwiftUIDecorationSurface: View { case .keyboardDeck: min(frame.height * 0.075, 24) case .applicationTarget, .launcherChoiceTarget, .handoffTarget: - min(frame.height * 0.34, 18) + // Full capsule: these are floating UI chips, not keycaps. + frame.height * 0.5 case .capsEcho: min(frame.width, frame.height) * 0.16 case .modifierToken: @@ -254,15 +255,37 @@ private struct KeyboardStageSwiftUIDecorationSurface: View { .shadow( color: Color(red: 0.18, green: 0.16, blue: 0.14) .opacity( - (decoration.kind == .keyboardDeck ? 0.18 : 0.10) + (decoration.kind == .keyboardDeck ? 0.18 : shadowOpacity) * Double(lighting.shadowStrength) ), - radius: decoration.kind == .keyboardDeck ? 12 : 4, - y: decoration.kind == .keyboardDeck ? 7 : 2 + radius: decoration.kind == .keyboardDeck ? 12 : shadowRadius, + y: decoration.kind == .keyboardDeck ? 7 : shadowOffsetY ) .frame(width: frame.width, height: frame.height) .rotationEffect(.radians(Double(decoration.rotationRadians))) .position(x: frame.midX, y: frame.midY) .opacity(Double(decoration.opacity)) } + + /// Floating chips carry a deeper, softer cast shadow than seated elements. + private var isFloatingChip: Bool { + switch decoration.kind { + case .applicationTarget, .launcherChoiceTarget, .handoffTarget: + true + case .keyboardDeck, .capsEcho, .modifierToken, .launcherCandidateMarker: + false + } + } + + private var shadowOpacity: Double { + isFloatingChip ? 0.16 : 0.10 + } + + private var shadowRadius: CGFloat { + isFloatingChip ? 9 : 4 + } + + private var shadowOffsetY: CGFloat { + isFloatingChip ? 5 : 2 + } } diff --git a/Sources/KeyPathAppKit/UI/Rules/FirstSuccessOnboardingDialog.swift b/Sources/KeyPathAppKit/UI/Rules/FirstSuccessOnboardingDialog.swift index 292e646e7..40cf8b0ee 100644 --- a/Sources/KeyPathAppKit/UI/Rules/FirstSuccessOnboardingDialog.swift +++ b/Sources/KeyPathAppKit/UI/Rules/FirstSuccessOnboardingDialog.swift @@ -218,6 +218,26 @@ private struct FirstSuccessCinematicLightWash: View { } } +private struct FirstSuccessCinematicWarmGraze: View { + let progress: Float + + var body: some View { + LinearGradient( + stops: [ + .init(color: .clear, location: 0.48), + .init(color: Color(red: 0.34, green: 0.22, blue: 0.15).opacity(0.08), location: 0.72), + .init(color: Color(red: 0.52, green: 0.34, blue: 0.23).opacity(0.18), location: 1), + ], + startPoint: .leading, + endPoint: .trailing + ) + .opacity(Double(1 - min(1, max(0, progress)))) + .blendMode(.screen) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + @MainActor private final class FirstSuccessWindowBackgroundView: NSView { var backgroundColor: NSColor { @@ -330,6 +350,9 @@ struct FirstSuccessOnboardingDialog: View { ) if !entranceFrame.reduceMotion { + FirstSuccessCinematicWarmGraze( + progress: entranceFrame.progress + ) FirstSuccessCinematicLightWash( progress: entranceFrame.progress ) @@ -383,7 +406,7 @@ struct FirstSuccessOnboardingDialog: View { ) FirstSuccessSeparator(horizontalInset: 14) - WizardButtonBar( + FirstSuccessActionBar( cancel: .init( title: String( localized: "Skip tour", @@ -554,6 +577,26 @@ struct FirstSuccessOnboardingDialog: View { } } +private struct FirstSuccessActionBar: View { + let cancel: WizardButtonBar.CancelButton? + let secondary: WizardButtonBar.SecondaryButton? + let primary: WizardButtonBar.PrimaryButton + let secondaryPlacement: WizardButtonBar.SecondaryPlacement + + @Environment(\.firstSuccessOnboardingPalette) private var palette + + var body: some View { + WizardButtonBar( + cancel: cancel, + secondary: secondary, + primary: primary, + secondaryPlacement: secondaryPlacement, + secondaryTint: palette.mutedText.color, + secondaryMinimumWidth: 72 + ) + } +} + private struct FirstSuccessSeparator: View { let horizontalInset: CGFloat @Environment(\.firstSuccessOnboardingPalette) private var palette @@ -679,11 +722,24 @@ private struct FirstSuccessJourneyContent: View { } } +private struct FirstSuccessCopyScrollMetrics: Equatable { + var overflows = false + var nearBottom = true +} + private struct FirstSuccessLessonCopy: View { let session: FirstSuccessOnboardingSession let launcherChoice: FirstSuccessLauncherChoiceModel let capsTapRevision: UInt64 @Environment(\.firstSuccessOnboardingPalette) private var palette + @State private var scrollMetrics = FirstSuccessCopyScrollMetrics() + + /// Lesson copy can exceed the column on the launcher step. The scroll view + /// hides its indicators, so without a fade the overflow reads as text + /// clipped mid-sentence instead of more content below. + private var showsOverflowFade: Bool { + scrollMetrics.overflows && !scrollMetrics.nearBottom + } var body: some View { ScrollView { @@ -718,6 +774,28 @@ private struct FirstSuccessLessonCopy: View { .padding(.trailing, 6) } .scrollIndicators(.hidden) + .onScrollGeometryChange(for: FirstSuccessCopyScrollMetrics.self) { geometry in + FirstSuccessCopyScrollMetrics( + overflows: geometry.contentSize.height + > geometry.containerSize.height + 1, + nearBottom: geometry.contentOffset.y + geometry.containerSize.height + >= geometry.contentSize.height - 8 + ) + } action: { _, metrics in + scrollMetrics = metrics + } + .mask { + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: showsOverflowFade ? 0.90 : 1), + .init(color: showsOverflowFade ? .clear : .black, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + .animation(.easeOut(duration: 0.15), value: showsOverflowFade) .accessibilityElement(children: .contain) .accessibilityIdentifier("first-success-onboarding-copy") } diff --git a/Sources/KeyPathInstallationWizard/UI/Components/WizardButtonBar.swift b/Sources/KeyPathInstallationWizard/UI/Components/WizardButtonBar.swift index 310d9159b..0f612a384 100644 --- a/Sources/KeyPathInstallationWizard/UI/Components/WizardButtonBar.swift +++ b/Sources/KeyPathInstallationWizard/UI/Components/WizardButtonBar.swift @@ -73,17 +73,23 @@ public struct WizardButtonBar: View { public let secondaryButton: SecondaryButton? public let primaryButton: PrimaryButton? public let secondaryPlacement: SecondaryPlacement + public let secondaryTint: Color + public let secondaryMinimumWidth: CGFloat public init( cancel: CancelButton? = nil, secondary: SecondaryButton? = nil, primary: PrimaryButton, - secondaryPlacement: SecondaryPlacement = .leading + secondaryPlacement: SecondaryPlacement = .leading, + secondaryTint: Color = WizardDesign.Colors.primaryAction, + secondaryMinimumWidth: CGFloat = 120 ) { cancelButton = cancel secondaryButton = secondary primaryButton = primary self.secondaryPlacement = secondaryPlacement + self.secondaryTint = secondaryTint + self.secondaryMinimumWidth = secondaryMinimumWidth } public var body: some View { @@ -93,7 +99,12 @@ public struct WizardButtonBar: View { Button(cancelButton.title) { cancelButton.action() } - .buttonStyle(WizardDesign.Component.SecondaryButton()) + .buttonStyle( + WizardDesign.Component.SecondaryButton( + tint: secondaryTint, + minimumWidth: secondaryMinimumWidth + ) + ) .keyboardShortcut(cancelButton.usesCancelShortcut ? .cancelAction : nil) .disabled(!cancelButton.isEnabled) .accessibilityIdentifier("wizard-cancel-button") @@ -104,7 +115,12 @@ public struct WizardButtonBar: View { Button(secondaryButton.title) { secondaryButton.action() } - .buttonStyle(WizardDesign.Component.SecondaryButton()) + .buttonStyle( + WizardDesign.Component.SecondaryButton( + tint: secondaryTint, + minimumWidth: secondaryMinimumWidth + ) + ) .disabled(!secondaryButton.isEnabled) .accessibilityIdentifier("wizard-secondary-button") } @@ -115,7 +131,12 @@ public struct WizardButtonBar: View { Button(secondaryButton.title) { secondaryButton.action() } - .buttonStyle(WizardDesign.Component.SecondaryButton()) + .buttonStyle( + WizardDesign.Component.SecondaryButton( + tint: secondaryTint, + minimumWidth: secondaryMinimumWidth + ) + ) .disabled(!secondaryButton.isEnabled) .accessibilityIdentifier("wizard-secondary-button") } diff --git a/Sources/KeyPathInstallationWizard/UI/WizardDesignSystem.swift b/Sources/KeyPathInstallationWizard/UI/WizardDesignSystem.swift index 92daaba54..7d8979f52 100644 --- a/Sources/KeyPathInstallationWizard/UI/WizardDesignSystem.swift +++ b/Sources/KeyPathInstallationWizard/UI/WizardDesignSystem.swift @@ -372,9 +372,19 @@ public enum WizardDesign { /// Secondary button style public struct SecondaryButton: ButtonStyle { public let isLoading: Bool + public let tint: Color + public let minimumWidth: CGFloat - public init(isLoading: Bool = false) { + @Environment(\.isEnabled) private var isEnabled + + public init( + isLoading: Bool = false, + tint: Color = WizardDesign.Colors.primaryAction, + minimumWidth: CGFloat = 120 + ) { self.isLoading = isLoading + self.tint = tint + self.minimumWidth = minimumWidth } public func makeBody(configuration: Configuration) -> some View { @@ -383,20 +393,20 @@ public enum WizardDesign { .foregroundColor( configuration.isPressed ? WizardDesign.Colors.wizardBackground // Invert for clear pressed feedback - : WizardDesign.Colors.primaryAction + : tint ) - .frame(minWidth: 120, minHeight: 26) // Match primary button dimensions + .frame(minWidth: minimumWidth, minHeight: 26) .padding(.horizontal, WizardDesign.Spacing.buttonPadding) .padding(.vertical, WizardDesign.Spacing.elementGap) .background( RoundedRectangle(cornerRadius: 8) - .fill(configuration.isPressed ? WizardDesign.Colors.primaryAction : Color.clear) + .fill(configuration.isPressed ? tint : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(WizardDesign.Colors.primaryAction, lineWidth: 1.5) + .stroke(tint, lineWidth: 1.25) ) - .opacity(isLoading ? 0.85 : 1.0) + .opacity(isEnabled ? (isLoading ? 0.85 : 1.0) : 0.42) .scaleEffect(configuration.isPressed ? 0.98 : 1.0) .animation(WizardDesign.Animation.buttonFeedback, value: configuration.isPressed) .disabled(isLoading) diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-caps-installed.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-caps-installed.png index dcf0e04fd..9a4b87d90 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-caps-installed.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-caps-installed.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-dark-room.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-dark-room.png index 9b85e8d77..ef23583c5 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-dark-room.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-dark-room.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-directional-light.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-directional-light.png index 3428f7adc..091eff09d 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-directional-light.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-directional-light.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-hyper-installed.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-hyper-installed.png index aa0586865..d15de7dc2 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-hyper-installed.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-hyper-installed.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-launcher-choice.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-launcher-choice.png index 5827d5fd1..2589d0267 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-launcher-choice.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-launcher-choice.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-reduced-motion.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-reduced-motion.png index 981ce15a9..7e714c278 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-reduced-motion.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-reduced-motion.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-rules-handoff.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-rules-handoff.png index 5897e8a57..6ee7dac63 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-rules-handoff.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessOnboardingSnapshotTests/assertStage-moment-displayMode-entrance-named.first-success-rules-handoff.png differ diff --git a/Tests/KeyPathTests/InstallationWizard/WizardButtonBarTests.swift b/Tests/KeyPathTests/InstallationWizard/WizardButtonBarTests.swift index 346b8554b..035afec82 100644 --- a/Tests/KeyPathTests/InstallationWizard/WizardButtonBarTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/WizardButtonBarTests.swift @@ -27,4 +27,15 @@ final class WizardButtonBarTests: XCTestCase { XCTAssertEqual(bar.secondaryPlacement, .trailing) } + + @MainActor + func testSecondaryButtonsCanUseACompactWidth() { + let bar = WizardButtonBar( + secondary: .init(title: "Back", action: {}), + primary: .init(title: "Continue", action: {}), + secondaryMinimumWidth: 72 + ) + + XCTAssertEqual(bar.secondaryMinimumWidth, 72) + } } diff --git a/Tests/KeyPathTests/KeyboardStage/KeyboardStageEntranceTests.swift b/Tests/KeyPathTests/KeyboardStage/KeyboardStageEntranceTests.swift index 22303afec..801ab98f7 100644 --- a/Tests/KeyPathTests/KeyboardStage/KeyboardStageEntranceTests.swift +++ b/Tests/KeyPathTests/KeyboardStage/KeyboardStageEntranceTests.swift @@ -34,14 +34,14 @@ final class KeyboardStageEntranceTests: XCTestCase { XCTAssertTrue(presentation.isAnimating) XCTAssertEqual(presentation.startedAt, 11.5) XCTAssertEqual( - presentation.frame(at: 11.775, pendingReduceMotion: false).progress, + presentation.frame(at: 11.875, pendingReduceMotion: false).progress, 0.5, accuracy: 0.001 ) presentation.advance(at: 12.0) XCTAssertTrue(presentation.isAnimating) - presentation.advance(at: 12.051) + presentation.advance(at: 12.251) presentation.beginIfNeeded(at: 20, reduceMotion: false) @@ -118,8 +118,14 @@ final class KeyboardStageEntranceTests: XCTestCase { XCTAssertGreaterThan(middle.lighting(for: caps).transientGlow, 0) XCTAssertEqual(middle.lighting(for: caps).legendTransitionProgress, 0) XCTAssertGreaterThan(middle.lighting(for: farKey).legendTransitionProgress, 0) + // The eased front dwells on the keyboard, so the far key completes its + // legend transition later than the linear halfway point. + let late = KeyboardStageLightingResolver( + scene: scene, + entrance: KeyboardStageEntranceFrame(progress: 0.8, reduceMotion: false) + ) XCTAssertEqual( - middle.lighting(for: farKey).legendTransitionProgress, + late.lighting(for: farKey).legendTransitionProgress, 1, accuracy: 0.001 ) @@ -169,7 +175,9 @@ final class KeyboardStageEntranceTests: XCTestCase { accuracy: 0.001 ) - let crossing = KeyboardStageEntranceFrame(progress: 0.5, reduceMotion: false) + // The eased front dwells on the keyboard, so it reaches mid-window + // later than the linear halfway point. + let crossing = KeyboardStageEntranceFrame(progress: 0.62, reduceMotion: false) XCTAssertGreaterThan( KeyboardStageCinematicLighting.exposure( for: crossing, @@ -194,7 +202,9 @@ final class KeyboardStageEntranceTests: XCTestCase { let end = KeyboardStageCinematicLighting.parameters(for: .settled) XCTAssertEqual(start.frontX, 1.08, accuracy: 0.001) - XCTAssertEqual(middle.frontX, 0.31, accuracy: 0.001) + // Ease-in travel: at half progress the front is still over the + // keyboard span rather than at the linear midpoint of 0.31. + XCTAssertEqual(middle.frontX, 0.6674, accuracy: 0.001) XCTAssertEqual(end.frontX, -0.46, accuracy: 0.001) XCTAssertEqual(end.feather, 0.40, accuracy: 0.001) XCTAssertEqual(end.verticalSkew, 0.06, accuracy: 0.001) @@ -288,13 +298,13 @@ final class KeyboardStageEntranceTests: XCTestCase { XCTAssertEqual(presentation.startedAt, 11.5) XCTAssertEqual( presentation.frame(at: 11.75, pendingReduceMotion: false).progress, - Float(0.25 / 0.55), + Float(0.25 / 0.75), accuracy: 0.001 ) var veryLatePresentation = KeyboardStageEntrancePresentation() veryLatePresentation.beginIfNeeded(at: 10, reduceMotion: false) - veryLatePresentation.advance(at: 12.1) + veryLatePresentation.advance(at: 12.3) XCTAssertTrue(veryLatePresentation.isSettled) } diff --git a/Tests/KeyPathTests/KeyboardStage/KeyboardStageLegendAtlasTests.swift b/Tests/KeyPathTests/KeyboardStage/KeyboardStageLegendAtlasTests.swift index 733d550d4..54135d807 100644 --- a/Tests/KeyPathTests/KeyboardStage/KeyboardStageLegendAtlasTests.swift +++ b/Tests/KeyPathTests/KeyboardStage/KeyboardStageLegendAtlasTests.swift @@ -22,15 +22,16 @@ final class KeyboardStageLegendAtlasTests: XCTestCase { } func testSignatureSortIncludesSecondaryTextAndWeight() throws { + let light = descriptor("A", weight: .light) let regular = descriptor("A") let medium = descriptor("A", weight: .medium) let secondary = descriptor("A", secondary: "alternate") let signature = try KeyboardStageLegendAtlasSignature( - descriptors: [secondary, medium, regular] + descriptors: [secondary, medium, regular, light] ) - XCTAssertEqual(signature.descriptors, [regular, medium, secondary]) + XCTAssertEqual(signature.descriptors, [light, regular, medium, secondary]) } func testCapacityCountsUniqueDescriptors() throws { diff --git a/Tests/KeyPathTests/KeyboardStage/KeyboardStageSceneTests.swift b/Tests/KeyPathTests/KeyboardStage/KeyboardStageSceneTests.swift index f02dd1d81..8cfc45ef1 100644 --- a/Tests/KeyPathTests/KeyboardStage/KeyboardStageSceneTests.swift +++ b/Tests/KeyPathTests/KeyboardStage/KeyboardStageSceneTests.swift @@ -347,6 +347,83 @@ final class KeyboardStageSceneTests: XCTestCase { XCTAssertTrue(scene.keys.contains { $0.keyCode == 57 }) } + func testKeyboardLegendsMatchMacBookCapitalizationAndNumberRowSymbols() throws { + let layout = PhysicalLayout( + id: "keyboard-stage-legend-test", + name: "Keyboard Stage Legend Test", + keys: [ + PhysicalKey(keyCode: 50, label: "`", x: 0, y: 0), + PhysicalKey(keyCode: 18, label: "1", x: 1.05, y: 0), + PhysicalKey(keyCode: 19, label: "2", x: 2.1, y: 0), + PhysicalKey(keyCode: 48, label: "tab", x: 0, y: 1.05, width: 1.5), + PhysicalKey(keyCode: 12, label: "q", x: 1.55, y: 1.05), + PhysicalKey(keyCode: 57, label: "caps", x: 0, y: 2.1, width: 1.75), + PhysicalKey(keyCode: 56, label: "shift", x: 0, y: 3.15, width: 2), + PhysicalKey(keyCode: 59, label: "control", x: 2.05, y: 3.15), + PhysicalKey(keyCode: 58, label: "option", x: 3.1, y: 3.15), + PhysicalKey(keyCode: 55, label: "command", x: 4.15, y: 3.15), + ] + ) + let scene = KeyboardStageSceneBuilder.make( + layout: layout, + keymap: .qwertyUS, + moment: .capsMotivation, + displayMode: .standard + ) + + func legend(_ keyCode: UInt16) throws -> KeyboardStageLegend { + try XCTUnwrap(scene.keys.first { $0.keyCode == keyCode }).legend + } + + XCTAssertEqual(try legend(50), KeyboardStageLegend(primary: "~", secondary: "`")) + XCTAssertEqual(try legend(18), KeyboardStageLegend(primary: "!", secondary: "1")) + XCTAssertEqual(try legend(19), KeyboardStageLegend(primary: "@", secondary: "2")) + XCTAssertEqual(try legend(48).primary, "tab") + XCTAssertEqual(try legend(12).primary, "Q") + XCTAssertEqual(try legend(57).primary, "caps lock") + XCTAssertEqual(try legend(56).primary, "shift") + XCTAssertEqual(try legend(59).primary, "control") + XCTAssertEqual(try legend(58).primary, "option") + XCTAssertEqual(try legend(55), KeyboardStageLegend(primary: "⌘", secondary: "command")) + } + + func testKeyboardNumberRowUsesShiftLegendsFromSelectedKeymap() throws { + let layout = PhysicalLayout( + id: "keyboard-stage-selected-keymap-legends", + name: "Keyboard Stage Selected Keymap Legends", + keys: [ + PhysicalKey(keyCode: 18, label: "1", x: 0, y: 0), + PhysicalKey(keyCode: 19, label: "2", x: 1.05, y: 0), + ] + ) + let keymap = LogicalKeymap( + id: "selected-keymap", + name: "Selected Keymap", + description: "Test keymap", + learnMoreURL: URL(string: "https://example.com")!, + iconFilename: "QWERTY", + coreLabels: [:], + extraLabels: [18: "&", 19: "é"], + shiftLabels: [18: "1", 19: "2"] + ) + + let scene = KeyboardStageSceneBuilder.make( + layout: layout, + keymap: keymap, + moment: .capsMotivation, + displayMode: .standard + ) + + XCTAssertEqual( + try XCTUnwrap(scene.keys.first { $0.keyCode == 18 }).legend, + KeyboardStageLegend(primary: "1", secondary: "&") + ) + XCTAssertEqual( + try XCTUnwrap(scene.keys.first { $0.keyCode == 19 }).legend, + KeyboardStageLegend(primary: "2", secondary: "é") + ) + } + func testReducedMotionApplyingScenesKeepMeaningWithoutSpatialMotion() throws { var reducedMotion = KeyboardStageDisplayMode.standard reducedMotion.reduceMotion = true @@ -523,6 +600,29 @@ final class KeyboardStageSceneTests: XCTestCase { } } + func testInstructionalRolesKeepAPhysicalKeyFaceAndUseTheRimForEmphasis() { + let roles: [KeyboardStageKeyRole] = [ + .recommended, .escape, .hyper, .launcher, .installed, + ] + + for appearance in [KeyboardStageDisplayMode.Appearance.light, .dark] { + var mode = KeyboardStageDisplayMode.standard + mode.appearance = appearance + let palette = KeyboardStagePalette(displayMode: mode) + let standard = palette.style(for: .standard) + + for role in roles { + let style = palette.style(for: role) + let faceDistance = abs(style.fill.red - standard.fill.red) + + abs(style.fill.green - standard.fill.green) + + abs(style.fill.blue - standard.fill.blue) + + XCTAssertLessThan(faceDistance, 0.16, "\(appearance) \(role)") + XCTAssertGreaterThan(style.borderStrength, standard.borderStrength) + } + } + } + func testReduceTransparencyKeepsPressedFaceButSuppressesEntranceGlow() { var displayMode = KeyboardStageDisplayMode.standard displayMode.reduceTransparency = true diff --git a/Tests/KeyPathTests/Services/SystemKeyLabelProviderTests.swift b/Tests/KeyPathTests/Services/SystemKeyLabelProviderTests.swift index a8f02fb73..62b19e432 100644 --- a/Tests/KeyPathTests/Services/SystemKeyLabelProviderTests.swift +++ b/Tests/KeyPathTests/Services/SystemKeyLabelProviderTests.swift @@ -147,6 +147,7 @@ final class SystemKeyLabelProviderTests: XCTestCase { let keymap = LogicalKeymap.system XCTAssertTrue(keymap.extraLabels.isEmpty, "System keymap should have no extra labels") XCTAssertEqual(keymap.coreLabels.count, 3, "All labels should be in coreLabels") + XCTAssertEqual(keymap.shiftLabels[18], "!") } } } diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 000000000..3002d196a --- /dev/null +++ b/design-qa.md @@ -0,0 +1,122 @@ +# First-success onboarding design QA + +## Source visual truth and evidence + +- Source: `/Users/malpern/Downloads/Generated image 1 (2).png` +- Installed dark-hold capture: `/tmp/keypath-onboarding-legends-final-verified-dark.png` +- Installed settled-light capture: `/tmp/keypath-onboarding-legends-final-verified-light.png` +- Full-view comparison: `/tmp/keypath-onboarding-legends-final-comparison.png` +- Focused keyboard comparison: `/tmp/keypath-onboarding-legend-focus-comparison.png` + +## Viewport and state normalization + +- Source pixels: 1506 x 1045. +- Implementation pixels: 1920 x 1364, captured from the installed 960 x 682-point window at 2x density with window shadow omitted. +- Full-view comparison: each complete window is aspect-fit into a 900 x 650 panel. No surrounding desktop or window shadow is included. +- Focused comparison: the keyboard regions are cropped using normalized window coordinates and rendered into equal 900 x 680 panels. +- State: first Caps Lock lesson, initial 1.5-second dark hold, Metal enabled. The settled-light endpoint was captured separately after the directional light rise. +- Installed artifact: `/Applications/KeyPath.app` launched through Help > Replay KeyPath Tour. + +## Findings + +No actionable P0, P1, or P2 mismatch remains in the reviewed state. + +- Fonts and typography: both use the native macOS system family and the same left-aligned hierarchy. Keyboard legends now use explicit macOS light faces for physical keys, lowercase functional labels, uppercase letters, equally weighted shifted/unshifted number-row pairs, and a stacked Command symbol/name. The implementation keeps slightly stronger instructional body contrast for accessibility. The longer benefit copy and specific action label are intentional product requirements, not visual drift. +- Spacing and layout rhythm: the title, feature rows, divider, footer actions, keyboard entry edge, and deck mass now align with the reference. The Caps camera uses restrained horizontal magnification so the key size matches without cropping the top or bottom rows. +- Colors and visual tokens: the near-black left field, warm right-side graze, graphite deck, white legend apertures, KeyPath blue, and exact light endpoint are aligned. The settled light view keeps softer wells instead of carrying the dark contact occlusion into daylight. +- Image and material quality: the Metal hero now separates the deck, recessed wells, contact occlusion, cast penumbra, key skirts, crowned key faces, microfacet response, procedural graphite/aluminum grain, directional backlight, and a concentrated white-blue Caps bevel catch. No raster texture or decorative approximation is used. +- Copy and content: copy remains benefit-led and task-specific. `Skip tour` and `Use Caps Lock for Escape` intentionally replace the reference's generic `Skip` and `Continue`. + +## Comparison history + +1. P1 — The baseline keyboard read as flat rounded rectangles with uniform cyan-gray perimeter light. Fixed with separate key wells, contact and area shadows, directional diffuser exposure, GGX material response, and localized lesson-key emission. Post-fix evidence: focused keyboard comparison. +2. P2 — Legends initially used generic UI typography rather than MacBook-like optical proportions. Fixed by using explicit macOS light faces and sizing the atlas so ordinary legends resolve to roughly 28% of the keycap height. Post-fix evidence: focused keyboard comparison. +3. P2 — The keyboard occupied too little of the dialog and showed too many columns. Fixed with an anisotropic Caps camera that increases horizontal presence while preserving vertical context. Post-fix evidence: full-view comparison. +4. P2 — The zoomed composition clipped the deck's rounded leading corner and first-column keys. Fixed by moving the Caps viewport focus left and lowering its vertical bias. Post-fix evidence: full-view and focused comparisons. +5. P2 — Light-mode wells retained overly hard dark rims. Fixed by independently reducing settled well, skirt, contact-shadow, and cast-shadow strength. Post-fix evidence: settled-light capture. +6. P2 — Keyboard legends omitted shifted number-row symbols, capitalized `Tab`, and rasterized selected/function legends too heavily. Fixed in the shared scene model and both render paths: lowercase functional labels, uppercase letters, paired symbols (`~`/`` ` ``, `!`/`1`, `@`/`2`, and so on), equal-size number-row stacking, and explicit macOS light font faces. Post-fix evidence: full-view and focused keyboard comparisons. + +## Follow-up polish + +- P3 — The generated reference uses slightly irregular, painterly highlights. The implementation stays deterministic and physically coherent, so exact per-pixel highlight placement differs while preserving the same lighting direction and depth hierarchy. +- P3 — Window traffic-light state varies with focus during automated capture and is outside the onboarding content hierarchy. + +## Iteration 2 — directional-light pass + +An independent installed-app review against the source image found the remaining +gap concentrated in one theme: the reference's light is directional while the +implementation's was symmetric. This pass addressed that in the Metal fragment +shader and the shared entrance model, verified from fresh installed replays. + +- Directional rims: every additive edge term (bevel specular, lower bevel + catch, inner rim, emphasis bevel, accent stroke) now follows a + light-facing mask during the dark hold, so keycap rims catch on the upper + right and fall away on the far side instead of outlining evenly. The lesson + key's dark-room rim resolves toward white with the blue halo outside, + matching the reference's bevel-catch grammar. +- Deck graze: the warm sweep reaches further left, roughly doubles in + strength, and pools through a large-scale noise mask so the aluminum reads + as a photographed area light rather than a uniform tint. +- Wells and shadows: dark-hold well envelopes widen and contact/cast shadows + deepen (entrance only — the settled light state keeps its original softer + weights), grounding each cap in a visible cavity. +- Legend field response: aperture brightness and warmth now vary along the + light axis (a ±20% spatial emission scale in the legend fragment), replacing + the perfectly even backlight panel. +- Physical press: a pressed cap now darkens toward its lower edge and catches + light on its top bevel instead of only swapping to a flat accent fill. +- Reveal pacing: the light front's travel is eased (`pow(progress, 1.9)`), so + roughly 60% of the 0.75 s reveal crosses the keyboard span; a capture burst + now lands multiple frames of the feathered front crossing individual + keycaps, where the linear sweep previously crossed them in ~0.28 s. +- Lesson copy overflow: the copy column shows a bottom fade whenever content + overflows and the reader is not at the bottom, so the launcher step's third + benefit row no longer appears clipped mid-sentence with no affordance. + +Evidence (installed replay, window-server capture): + +- Dark hold: `/tmp/keypath-onboarding-directional-dark.png` +- Front crossing keycaps: `/tmp/keypath-onboarding-directional-midcross.png` +- Settled light endpoint (unchanged): `/tmp/keypath-onboarding-directional-light.png` + +## Iteration 3 — legend alignment and floating chips + +The two follow-ups from the directional pass: + +- Wide-key legend alignment: the scene model now carries a legend alignment, + and MacBook conventions are applied from key geometry — `tab`, `caps lock`, + and left `shift` set leading, row-ending wide keys trailing, everything + else centered. The Metal legend atlas rasterizes edge-aligned cells flush + to a fixed padding and the renderer anchors the quad's matching edge at the + keycap's inner margin; the native overlay applies the same alignment for + the fallback and accessibility layers. +- Floating chips: the launcher-choice and Rules-handoff affordances no longer + wear a keycap costume. They render as full capsules with flattened edges, a + top sheen, a reduced contact shadow, and a deeper, softer cast shadow in + both render paths, reading as UI chips hovering above the spacebar rather + than replacing it. + +Both changes verified in the re-recorded fallback snapshots (all seven +states) and in an installed Metal-path capture of the settled state, which +confirms the edge-anchored legend quads render correctly on the GPU path. + +Additionally, per hands-on feedback that the board's bottom (and after the +first fix, top) edge was cut off: the deck now carries a wider aluminum apron +below the bottom row, and the projection's anisotropic camera no longer zooms +vertically above 1x, so the complete board — top edge through bottom apron, +with both rounded corners — stays in frame in every moment. The horizontal +magnification that matches the reference key width is unchanged; the visible +keyboard is slightly smaller vertically as an accepted tradeoff for never +cropping the object. + +## Validation + +- `git diff --check`: passed. +- Keyboard-stage scene and legend-atlas tests: 32 runner tests passed. +- Required catalog-sequence regression filter: 3 runner tests passed, including both named XCTest cases. +- Accessibility identifier check: 378 files checked, passed. +- Installed-app dark and light states inspected from a fresh replay: passed. + +## Final result + +passed diff --git a/docs/planning/first-success-onboarding-metal-plan.md b/docs/planning/first-success-onboarding-metal-plan.md index ffaed8324..6d30597da 100644 --- a/docs/planning/first-success-onboarding-metal-plan.md +++ b/docs/planning/first-success-onboarding-metal-plan.md @@ -75,7 +75,7 @@ traveling light front. The entrance is a bounded proof gate for Metal, not an open-ended visual-effects project. The forced-Metal installed-app capture must demonstrate all of the -following in the 1.5-second dark hold and one approximately 0.55-second +following in the 1.5-second dark hold and one approximately 0.75-second directional reveal: - convincing dark graphite key materials with neutral, MacBook-like emitted