diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageEntrance.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageEntrance.swift index d232d3c6a..146de736d 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageEntrance.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/KeyboardStageEntrance.swift @@ -254,7 +254,7 @@ struct KeyboardStageEntrancePresentation: Equatable, Sendable { case settled } - static let holdDuration: TimeInterval = 1.50 + static let holdDuration: TimeInterval = 2.00 static let transitionDuration: TimeInterval = 0.75 static let reducedMotionTransitionDuration: TimeInterval = 0.25 diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal index b6029df15..578ab7a6b 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal @@ -9,6 +9,7 @@ struct KeyboardStageInstance { float4 parameters; // pressure, glow, opacity, border strength float4 treatment; // rotation, corner ratio, shadow margin in pixels, reserved float4 lighting; // illumination, transient glow, shadow strength, interaction level + float4 material; // wear level, stable per-key seed, reserved }; struct KeyboardStageUniforms { @@ -22,6 +23,7 @@ struct KeyboardStageUniforms { float4 tuningB; // graze mask start, rim peak, rim power, emphasis rim floor float4 tuningC; // well expansion, contact strength, cast strength, backlight float4 tuningD; // legend emission min/max, legend base min/max + float4 tuningE; // fill light, fill specular, wear strength, highlight jitter }; struct KeyboardStageLegendInstance { @@ -34,6 +36,7 @@ struct KeyboardStageLegendInstance { struct KeyboardStagePostUniforms { float4 sourceAndBloomSize; // source width/height, bloom width/height float4 entranceAndDirection; // progress, Reduce Motion flag, light direction.xy + float4 vignette; // strength, inner radius, stage windowX origin/width }; struct KeyboardStageVertexOut { @@ -48,6 +51,7 @@ struct KeyboardStageVertexOut { float cornerRadiusPixels; float2 stageUV; float materialKind; + float2 material; }; struct KeyboardStageLegendVertexOut { @@ -111,6 +115,7 @@ vertex KeyboardStageVertexOut keypath_keyboard_stage_vertex( 0.5 - output.position.y * 0.5 ); output.materialKind = instance.treatment.w; + output.material = instance.material.xy; return output; } @@ -321,6 +326,15 @@ fragment float4 keypath_keyboard_stage_fragment( // 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; + // Real keycaps are not uniform: fingers polish high-traffic caps and no + // two caps sit at exactly the same angle. Wear and a stable per-key seed + // drive gloss, grain, and highlight variation; both derive from the key + // code, so the pattern never shifts between moments or launches. + float wear = isKey + ? saturate(input.material.x) * saturate(uniforms.tuningE.z) + : 0.0; + float materialSeedA = keypath_hash(float2(input.material.y, 0.37)); + float materialSeedB = keypath_hash(float2(input.material.y, 0.71)); bool reduceMotion = uniforms.entrance.y > 0.5; float pixelExposure = keypath_cinematic_exposure( uniforms.entrance.x, @@ -497,6 +511,13 @@ fragment float4 keypath_keyboard_stage_fragment( ); float faceMask = isKey ? (1.0 - bevel) * surfaceAlpha : 0.0; float2 crownNormal = faceCoordinate * float2(0.055, 0.075) * faceMask; + // Each cap sits at a fractionally different angle, so its highlight lands + // slightly apart from its neighbors' — the variation that reads as + // photographed hardware instead of instanced geometry. + crownNormal += (float2(materialSeedA, materialSeedB) - 0.5) + * 0.055 + * uniforms.tuningE.w + * faceMask; float3 normal = normalize(float3( distanceGradient.x * bevel * bevelDepth + crownNormal.x, distanceGradient.y * bevel * bevelDepth + crownNormal.y, @@ -516,7 +537,7 @@ fragment float4 keypath_keyboard_stage_fragment( ? sin(materialPixels.y * 1.82 + keypath_value_noise(materialPixels * 0.012) * 4.0) * 0.0038 : 0.0; - float microNormalStrength = isDeck ? 0.026 : 0.014; + float microNormalStrength = isDeck ? 0.026 : 0.014 * (1.0 - wear * 0.45); normal = normalize(normal + float3( microX * microNormalStrength, microY * microNormalStrength + brushedRidge, @@ -541,7 +562,10 @@ fragment float4 keypath_keyboard_stage_fragment( 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), + (isDeck ? 0.31 : 0.47) + + roughnessVariation * (isDeck ? 0.055 : 0.035) + - wear * 0.11 + + (isKey ? (materialSeedA - 0.5) * 0.05 : 0.0), 0.12, 0.82 ); @@ -560,7 +584,11 @@ fragment float4 keypath_keyboard_stage_fragment( * 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 keySpecularGain = isKey + ? (0.85 + materialSeedB * 0.30) * (1.0 + wear * 0.55) + : 1.0; + float specular = pow(saturate(dot(normal, halfVector)), specularPower) + * keySpecularGain; float bevelSpecular = pow( saturate(dot(normal, halfVector)), isDeck ? 112.0 : 64.0 @@ -581,7 +609,9 @@ fragment float4 keypath_keyboard_stage_fragment( // 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 + ? pow(saturate(1.0 - length(faceCoordinate) * 0.52), 2.2) + * faceMask + * (1.0 + wear * 1.1) : 0.0; float lowerBevelCatch = isKey ? bevel * smoothstep(0.05, 0.82, distanceGradient.y) @@ -613,6 +643,7 @@ fragment float4 keypath_keyboard_stage_fragment( surfaceColor += microfacetSpecular * neutralLight * normalDotLight + * keySpecularGain * mix(isDeck ? 0.032 : 0.018, isDeck ? 0.22 : 0.11, illumination); surfaceColor += neutralLight * crownHighlight @@ -665,6 +696,25 @@ fragment float4 keypath_keyboard_stage_fragment( * grazePool * (warmMaterialResponse + warmBevelResponse); + // A dim cool fill from the lower left mirrors the second source of a + // studio rig: it lifts the shadow side just enough to give highlights a + // two-temperature complexity instead of a single clinical lobe. + float3 fillDirection = normalize(float3(-0.55, 0.35, 0.62)); + float fillDiffuse = saturate(dot(normal, fillDirection)); + float3 fillHalfVector = normalize(fillDirection + float3(0.0, 0.0, 1.0)); + float fillSpecular = pow( + saturate(dot(normal, fillHalfVector)), + isDeck ? 60.0 : 40.0 + ); + // The dark room's materials sit near black in linear light, so the fill + // must stay a whisper there or it grays the whole scene; the settled + // light state can carry more of it invisibly. + float fillStrength = uniforms.tuningE.x * mix(0.10, 0.35, illumination); + surfaceColor += float3(0.52, 0.60, 0.76) + * fillStrength + * (fillDiffuse * (isDeck ? 0.050 : 0.034) + + fillSpecular * bevel * uniforms.tuningE.y); + 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 @@ -1012,6 +1062,23 @@ fragment float4 keypath_keyboard_composite_fragment( float3 straightRadiance = premultipliedRadiance / finalAlpha; float3 mappedColor = keypath_highlight_shoulder(straightRadiance); + // The reference photograph sinks toward the frame corners. The falloff is + // computed in window space so it stays anchored to the dialog, and it + // relaxes as the light rises so the settled state keeps only a trace. + float2 windowUV = float2( + uniforms.vignette.z + input.uv.x * uniforms.vignette.w, + input.uv.y + ); + float vignetteDistance = length((windowUV - float2(0.5)) * float2(1.25, 1.0)); + float vignetteFalloff = smoothstep( + uniforms.vignette.y, + uniforms.vignette.y + 0.38, + vignetteDistance + ) + * uniforms.vignette.x + * mix(1.0, 0.30, easedProgress); + mappedColor *= 1.0 - vignetteFalloff; + // Static sub-LSB noise prevents banding in the dark-to-light shoulder. It // fades out before progress reaches one so the final light frame is clean. float2 sourceSize = max(uniforms.sourceAndBloomSize.xy, float2(1.0)); diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageMetalRenderer.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageMetalRenderer.swift index b9ad67b96..af0609ff7 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageMetalRenderer.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageMetalRenderer.swift @@ -10,6 +10,7 @@ private struct KeyboardStageGPUInstance { var parameters: SIMD4 var treatment: SIMD4 var lighting: SIMD4 + var material: SIMD4 } private struct KeyboardStageGPUUniforms { @@ -21,6 +22,7 @@ private struct KeyboardStageGPUUniforms { var tuningB: SIMD4 var tuningC: SIMD4 var tuningD: SIMD4 + var tuningE: SIMD4 } private struct KeyboardStageGPULegendInstance { @@ -33,6 +35,7 @@ private struct KeyboardStageGPULegendInstance { private struct KeyboardStageGPUPostUniforms { var sourceAndBloomSize: SIMD4 var entranceAndDirection: SIMD4 + var vignette: SIMD4 } private struct KeyboardStageMetalRenderTargets { @@ -199,7 +202,8 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se tuningA: currentTuning.gpuVectorA, tuningB: currentTuning.gpuVectorB, tuningC: currentTuning.gpuVectorC, - tuningD: currentTuning.gpuVectorD + tuningD: currentTuning.gpuVectorD, + tuningE: currentTuning.gpuVectorE ) var postUniforms = KeyboardStageGPUPostUniforms( sourceAndBloomSize: SIMD4( @@ -213,6 +217,12 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se currentFrame.entrance.reduceMotion ? 1 : 0, 1, 0 + ), + vignette: SIMD4( + currentTuning.vignetteVector.x, + currentTuning.vignetteVector.y, + currentWindowX.x, + currentWindowX.y ) ) @@ -320,7 +330,8 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se tuningA: capturedTuning.gpuVectorA, tuningB: capturedTuning.gpuVectorB, tuningC: capturedTuning.gpuVectorC, - tuningD: capturedTuning.gpuVectorD + tuningD: capturedTuning.gpuVectorD, + tuningE: capturedTuning.gpuVectorE ) var postUniforms = KeyboardStageGPUPostUniforms( sourceAndBloomSize: SIMD4( @@ -334,6 +345,12 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se frame.entrance.reduceMotion ? 1 : 0, 1, 0 + ), + vignette: SIMD4( + capturedTuning.vignetteVector.x, + capturedTuning.vignetteVector.y, + windowX.x, + windowX.y ) ) @@ -934,6 +951,8 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se lighting: lighting.lighting(for: key), cornerRatio: 0.31, materialKind: 1, + wear: Self.wearLevel(for: key.keyCode), + seed: Self.materialSeed(for: key.keyCode), drawableSize: drawableSize ) }) @@ -1018,6 +1037,8 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se lighting: KeyboardStageSurfaceLighting, cornerRatio: Float, materialKind: Float, + wear: Float = 0, + seed: Float = 0.5, drawableSize: CGSize ) -> KeyboardStageGPUInstance { let width = max(1, Float(drawableSize.width)) @@ -1050,10 +1071,36 @@ final class KeyboardStageMetalRenderer: NSObject, MTKViewDelegate, @unchecked Se lighting.transientGlow, lighting.shadowStrength, interactionLevel - ) + ), + material: SIMD4(wear, seed, 0, 0) ) } + /// Fingers polish real keycaps unevenly: home-row and thumb-cluster caps + /// go glossy first, common letters follow, and the number row keeps its + /// factory texture. Values are relative gloss, tuned by feel. + private static func wearLevel(for keyCode: UInt16) -> Float { + switch keyCode { + case 49: 0.85 // space + case 0, 1, 2, 3, 38, 40, 37, 41: 0.75 // a s d f j k l ; + case 57, 36, 51: 0.65 // caps lock, return, delete + case 56, 60, 55, 54, 58, 61, 59, 62, 63: 0.55 // shift, command, option, control, fn + case 14, 17, 31, 34, 45, 15, 32, 4: 0.5 // e t o i n r u h + case 12, 13, 16, 5, 35, 8, 9, 7, 6, 11, 46, 43, 47, 44: 0.35 // remaining letters/punct + case 18 ... 29, 50: 0.15 // number row + default: 0.3 + } + } + + /// A stable per-key seed so material variation and highlight jitter stay + /// fixed across moments, camera changes, and app launches. + private static func materialSeed(for keyCode: UInt16) -> Float { + var value = UInt32(keyCode) &* 2_654_435_761 + value = (value ^ (value >> 16)) &* 2_246_822_519 + value = value ^ (value >> 13) + return Float(value % 10000) / 10000 + } + private func linearColor(_ color: KeyboardStageRGBA) -> SIMD4 { SIMD4( linearComponent(color.red), diff --git a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageTuning.swift b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageTuning.swift index c19ee4581..b14dc65d0 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageTuning.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageTuning.swift @@ -45,6 +45,16 @@ struct KeyboardStageTuning: Equatable, Sendable { /// Legend base-brightness range across the light axis in the dark hold. var legendBaseMin: Float = 0.84 var legendBaseMax: Float = 1.05 + /// Cool fill light from the lower left: overall and specular strength. + var fillLightStrength: Float = 0.95 + var fillSpecularStrength: Float = 0.15 + /// Per-key wear (polished crowns, calmer grain on high-traffic caps). + var wearStrength: Float = 1.5 + /// Per-key highlight jitter (each cap catches the light slightly apart). + var highlightJitter: Float = 2.2 + /// Window-space corner falloff; relaxes to 30% at the settled light. + var vignetteStrength: Float = 0.58 + var vignetteInnerRadius: Float = 0.33 static let `default` = KeyboardStageTuning() @@ -70,6 +80,16 @@ struct KeyboardStageTuning: Equatable, Sendable { var gpuVectorD: SIMD4 { SIMD4(legendEmissionMin, legendEmissionMax, legendBaseMin, legendBaseMax) } + + var gpuVectorE: SIMD4 { + SIMD4(fillLightStrength, fillSpecularStrength, wearStrength, highlightJitter) + } + + /// Packed into the post-pass uniforms; the renderer appends the stage's + /// window mapping to the remaining two lanes. + var vignetteVector: SIMD2 { + SIMD2(vignetteStrength, vignetteInnerRadius) + } } extension KeyboardStageTuning: Codable { @@ -90,6 +110,12 @@ extension KeyboardStageTuning: Codable { case legendEmissionMax case legendBaseMin case legendBaseMax + case fillLightStrength + case fillSpecularStrength + case wearStrength + case highlightJitter + case vignetteStrength + case vignetteInnerRadius } init(from decoder: any Decoder) throws { @@ -119,6 +145,12 @@ extension KeyboardStageTuning: Codable { legendEmissionMax = value(.legendEmissionMax, defaults.legendEmissionMax) legendBaseMin = value(.legendBaseMin, defaults.legendBaseMin) legendBaseMax = value(.legendBaseMax, defaults.legendBaseMax) + fillLightStrength = value(.fillLightStrength, defaults.fillLightStrength) + fillSpecularStrength = value(.fillSpecularStrength, defaults.fillSpecularStrength) + wearStrength = value(.wearStrength, defaults.wearStrength) + highlightJitter = value(.highlightJitter, defaults.highlightJitter) + vignetteStrength = value(.vignetteStrength, defaults.vignetteStrength) + vignetteInnerRadius = value(.vignetteInnerRadius, defaults.vignetteInnerRadius) } /// Loads overrides from a JSON file; unknown keys are ignored and missing diff --git a/Tests/KeyPathSnapshotTests/KeyboardStageTuningPreviewTests.swift b/Tests/KeyPathSnapshotTests/KeyboardStageTuningPreviewTests.swift new file mode 100644 index 000000000..950b2be37 --- /dev/null +++ b/Tests/KeyPathSnapshotTests/KeyboardStageTuningPreviewTests.swift @@ -0,0 +1,75 @@ +import AppKit +@testable import KeyPathAppKit +import Metal +import XCTest + +/// Offscreen tuning previews: renders the first-lesson dark hold and settled +/// frames through the exact Metal pipeline into a scratch directory, honoring +/// `KEYPATH_STAGE_TUNING_FILE` overrides. This gives lighting-tuning sessions +/// a deterministic, screen-free loop — no installed app, no window automation. +/// +/// KEYPATH_STAGE_TUNING_FILE=~/t.json \ +/// KEYPATH_STAGE_PREVIEW_DIR=/tmp/stage-previews \ +/// swift test --filter KeyboardStageTuningPreviewTests +/// +/// Skips unless `KEYPATH_STAGE_PREVIEW_DIR` is set, so it never runs in CI or +/// ordinary suites and never touches the recorded goldens. +@MainActor +final class KeyboardStageTuningPreviewTests: XCTestCase { + func testRenderTuningPreviews() throws { + guard let outputPath = ProcessInfo.processInfo + .environment["KEYPATH_STAGE_PREVIEW_DIR"], !outputPath.isEmpty + else { + throw XCTSkip("Set KEYPATH_STAGE_PREVIEW_DIR to render tuning previews.") + } + guard let device = MTLCreateSystemDefaultDevice() else { + throw XCTSkip("Metal is not available on this machine.") + } + + let outputDirectory = URL(fileURLWithPath: (outputPath as NSString).expandingTildeInPath) + try FileManager.default.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true + ) + + let scene = KeyboardStageSceneBuilder.make( + layout: .macBookUS, + keymap: .qwertyUS, + moment: .capsMotivation, + displayMode: .standard + ) + let renderer = try KeyboardStageMetalRenderer( + device: device, + frame: KeyboardStagePresentedFrame( + scene: scene, + entrance: KeyboardStageEntranceFrame(progress: 0, reduceMotion: false) + ), + onFirstFramePresented: {}, + onFailure: { _ in } + ) + if let tuningURL = KeyboardStageTuning.environmentFileURL { + try renderer.update(tuning: KeyboardStageTuning.load(from: tuningURL)) + } + + for (name, progress) in [("dark", Float(0)), ("mid", 0.5), ("settled", 1)] { + let capture = try renderer.captureFrame( + frame: KeyboardStagePresentedFrame( + scene: scene, + entrance: KeyboardStageEntranceFrame( + progress: progress, + reduceMotion: false + ) + ), + drawableSize: CGSize(width: 1800, height: 1040), + windowX: SIMD2(0.42, 0.58) + ) + let destination = outputDirectory.appendingPathComponent("preview-\(name).png") + let representation = NSBitmapImageRep(cgImage: capture.image) + let data = try XCTUnwrap(representation.representation( + using: .png, + properties: [:] + )) + try data.write(to: destination) + } + } +} diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-dark-hold.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-dark-hold.png index ad7298413..3fae3d10b 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-dark-hold.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-dark-hold.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-25.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-25.png index 32f468cf1..cb1a50048 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-25.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-25.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-50.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-50.png index c915fef03..24238a18d 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-50.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-50.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-75.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-75.png index 185671654..c5598cb14 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-75.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-reveal-75.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-settled.png b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-settled.png index b94a672bf..2d488cf6f 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-settled.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/FirstSuccessMetalStageSnapshotTests/testMetalEntranceContractFrames.metal-settled.png differ diff --git a/Tests/KeyPathTests/KeyboardStage/KeyboardStageEntranceTests.swift b/Tests/KeyPathTests/KeyboardStage/KeyboardStageEntranceTests.swift index 801ab98f7..8a2c5edb2 100644 --- a/Tests/KeyPathTests/KeyboardStage/KeyboardStageEntranceTests.swift +++ b/Tests/KeyPathTests/KeyboardStage/KeyboardStageEntranceTests.swift @@ -21,27 +21,27 @@ final class KeyboardStageEntranceTests: XCTestCase { XCTAssertTrue(presentation.hasPendingWork) XCTAssertFalse(presentation.isAnimating) XCTAssertEqual( - presentation.frame(at: 11.49, pendingReduceMotion: false).progress, + presentation.frame(at: 11.99, pendingReduceMotion: false).progress, 0 ) - XCTAssertEqual(presentation.remainingDuration(at: 10), 1.5, accuracy: 0.001) + XCTAssertEqual(presentation.remainingDuration(at: 10), 2.0, accuracy: 0.001) - presentation.advance(at: 11.49) + presentation.advance(at: 11.99) XCTAssertEqual(presentation.revision, revision) XCTAssertFalse(presentation.isAnimating) - presentation.advance(at: 11.5) + presentation.advance(at: 12.0) XCTAssertTrue(presentation.isAnimating) - XCTAssertEqual(presentation.startedAt, 11.5) + XCTAssertEqual(presentation.startedAt, 12.0) XCTAssertEqual( - presentation.frame(at: 11.875, pendingReduceMotion: false).progress, + presentation.frame(at: 12.375, pendingReduceMotion: false).progress, 0.5, accuracy: 0.001 ) - presentation.advance(at: 12.0) + presentation.advance(at: 12.5) XCTAssertTrue(presentation.isAnimating) - presentation.advance(at: 12.251) + presentation.advance(at: 12.751) presentation.beginIfNeeded(at: 20, reduceMotion: false) @@ -65,10 +65,10 @@ final class KeyboardStageEntranceTests: XCTestCase { XCTAssertEqual(presentation.startedAt, 10) XCTAssertEqual( - presentation.frame(at: 11.49, pendingReduceMotion: false).progress, + presentation.frame(at: 11.99, pendingReduceMotion: false).progress, 0 ) - XCTAssertEqual(presentation.remainingDuration(at: 10), 1.5, accuracy: 0.001) + XCTAssertEqual(presentation.remainingDuration(at: 10), 2.0, accuracy: 0.001) } func testRegularEntranceUsesDirectionalGraphiteSurfacesAndBacklitLegends() throws { @@ -274,17 +274,17 @@ final class KeyboardStageEntranceTests: XCTestCase { var presentation = KeyboardStageEntrancePresentation() presentation.beginIfNeeded(at: 5, reduceMotion: true) - XCTAssertEqual(presentation.remainingDuration(at: 5), 1.5, accuracy: 0.001) + XCTAssertEqual(presentation.remainingDuration(at: 5), 2.0, accuracy: 0.001) XCTAssertEqual( - presentation.frame(at: 6.49, pendingReduceMotion: false).progress, + presentation.frame(at: 6.99, pendingReduceMotion: false).progress, 0 ) - presentation.advance(at: 6.5) + presentation.advance(at: 7.0) XCTAssertTrue(presentation.isAnimating) - XCTAssertEqual(presentation.remainingDuration(at: 6.5), 0.25, accuracy: 0.001) + XCTAssertEqual(presentation.remainingDuration(at: 7.0), 0.25, accuracy: 0.001) XCTAssertTrue( - presentation.frame(at: 6.625, pendingReduceMotion: false).reduceMotion + presentation.frame(at: 7.125, pendingReduceMotion: false).reduceMotion ) } @@ -292,19 +292,19 @@ final class KeyboardStageEntranceTests: XCTestCase { var presentation = KeyboardStageEntrancePresentation() presentation.beginIfNeeded(at: 10, reduceMotion: false) - presentation.advance(at: 11.75) + presentation.advance(at: 12.25) XCTAssertTrue(presentation.isAnimating) - XCTAssertEqual(presentation.startedAt, 11.5) + XCTAssertEqual(presentation.startedAt, 12.0) XCTAssertEqual( - presentation.frame(at: 11.75, pendingReduceMotion: false).progress, + presentation.frame(at: 12.25, pendingReduceMotion: false).progress, Float(0.25 / 0.75), accuracy: 0.001 ) var veryLatePresentation = KeyboardStageEntrancePresentation() veryLatePresentation.beginIfNeeded(at: 10, reduceMotion: false) - veryLatePresentation.advance(at: 12.3) + veryLatePresentation.advance(at: 12.8) XCTAssertTrue(veryLatePresentation.isSettled) } diff --git a/Tests/KeyPathTests/KeyboardStage/KeyboardStageTuningTests.swift b/Tests/KeyPathTests/KeyboardStage/KeyboardStageTuningTests.swift index 911b50b08..595050dad 100644 --- a/Tests/KeyPathTests/KeyboardStage/KeyboardStageTuningTests.swift +++ b/Tests/KeyPathTests/KeyboardStage/KeyboardStageTuningTests.swift @@ -31,6 +31,8 @@ final class KeyboardStageTuningTests: XCTestCase { XCTAssertEqual(tuning.gpuVectorB.y, tuning.rimDirectionalityPeak) XCTAssertEqual(tuning.gpuVectorC.w, tuning.backlightStrengthDark) XCTAssertEqual(tuning.gpuVectorD.y, tuning.legendEmissionMax) + XCTAssertEqual(tuning.gpuVectorE.x, tuning.fillLightStrength) + XCTAssertEqual(tuning.gpuVectorE.z, tuning.wearStrength) } func testEnvironmentFileURLExpandsTilde() { diff --git a/docs/planning/first-success-onboarding-metal-plan.md b/docs/planning/first-success-onboarding-metal-plan.md index 6d30597da..b2fec1ff4 100644 --- a/docs/planning/first-success-onboarding-metal-plan.md +++ b/docs/planning/first-success-onboarding-metal-plan.md @@ -65,7 +65,7 @@ Motion is tightly choreographed: immediate response at pointer/key down; roughly The keyboard's one-time entrance establishes that physical world before the first lesson: the dialog opens as a still, dark room with MacBook-like backlit -legends, holds for 1.5 seconds after the first keyboard frame is actually +legends, holds for 2 seconds after the first keyboard frame is actually presented, then a broad feathered light enters from the keyboard side and resolves the whole window into the reference-light design. The illumination is directional but never a hard UI wipe; Metal gives individual key surfaces a @@ -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.75-second +following in the 2-second dark hold and one approximately 0.75-second directional reveal: - convincing dark graphite key materials with neutral, MacBook-like emitted