Skip to content

Add per-key wear and highlight variation to the keyboard stage - #1243

Merged
malpern merged 3 commits into
masterfrom
claude/stage-wear-and-highlights
Jul 29, 2026
Merged

Add per-key wear and highlight variation to the keyboard stage#1243
malpern merged 3 commits into
masterfrom
claude/stage-wear-and-highlights

Conversation

@malpern

@malpern malpern commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Per-key wear (keycode-seeded): high-traffic caps get polished crowns, calmer micro-grain, and stronger highlight response; the number row keeps factory texture
  • Per-key highlight jitter: each cap's crown tilts fractionally so highlights land apart, breaking the instanced-geometry uniformity
  • Cool lower-left fill light with two-temperature speculars, kept to a whisper during the dark hold
  • All behaviors exposed as KeyboardStageTuning knobs (zero restores the prior look); Metal goldens re-recorded

Review gate

remote review gate selected — GitHub claude-review is the enforced reviewer.

Testing

  • Metal golden-image suite re-recorded and re-compared clean
  • Keyboard-stage scene/entrance/tuning suites pass
  • Accessibility identifier check passes
  • GPU frame times measured: 2.8–6.9 ms full pipeline at 1800×1040 (worst at mid-reveal)

🤖 Generated with Claude Code

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewed the diff. Implementation looks solid overall — the keyCode → wear-tier mapping in wearLevel(for:) correctly matches macOS virtual keycodes (home row, modifiers, number row, etc. all check out), and the new GPU struct fields (material / tuningE) are appended consistently on both the Metal and Swift sides with matching layout.

One actionable gap:

  • KeyboardStageTuningTests.testTuningVectorsMatchFields only asserts gpuVectorE.x == fillLightStrength and gpuVectorE.z == wearStrength, but not .y (fillSpecularStrength) or .w (highlightJitter). Since gpuVectorE packs 4 independent tuning values, a future field-order swap (e.g. fillSpecularStrength and highlightJitter transposed) would silently pass this test and only show up as a subtle rendering diff. Worth adding the missing two assertions for full coverage, matching the pattern used for tuningAtuningD.

Minor/non-blocking:

  • wear is scaled by saturate(uniforms.tuningE.z) (clamped to [0,1]), but tuningE.x/tuningE.y/tuningE.w (fill light, fill specular, highlight jitter) are used unclamped. If these are meant to be tunable multipliers during art iteration, this inconsistency could surprise someone dialing wearStrength above 1.0 expecting a stronger effect.
  • wearLevel(for:) / materialSeed(for:) are only exercised indirectly through the regenerated snapshot PNGs — no unit test pins the expected wear tier for a given keyCode, so a future keycode typo wouldn't fail until a snapshot diff is manually eyeballed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9114083068

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStage.metal
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Found one actionable correctness issue in the shader tuning:

wearStrength tuning knob is silently capped at 1.0, but its own default (1.5) already exceeds that cap.

In KeyboardStage.metal:

float wear = isKey
    ? saturate(input.material.x) * saturate(uniforms.tuningE.z)
    : 0.0;

uniforms.tuningE.z is wearStrength, whose default in KeyboardStageTuning.swift is 1.5. Wrapping it in saturate(...) clamps it to [0,1], so any value ≥ 1.0 (including the shipped default) behaves identically to 1.0 — the field can never actually scale wear beyond full strength, and the "1.5" default is misleading since it has no effect over 1.0. This is inconsistent with the sibling fields in the same tuningE vector (fillLightStrength, fillSpecularStrength, highlightJitter), none of which are saturated before use. Either drop the saturate(...) around uniforms.tuningE.z (if it's meant to be a >1 multiplier like the others) or lower the default to ≤1.0 to match the actual clamp — as-is the tuning surface for wear is silently narrower than it appears.

Minor: KeyboardStageTuningTests.testGpuVectorsMatchFields only asserts gpuVectorE.x/.z, and there's no assertion for vignetteVector — worth covering .y/.w (fillSpecularStrength/highlightJitter) and vignetteVector too, since that's exactly the kind of field-ordering mismatch that's easy to introduce silently in these packed SIMD4 vectors.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Correctness: wearStrength tuning default is clamped to a no-op range by the shader.

KeyboardStageTuning.wearStrength defaults to 1.5 (Sources/KeyPathAppKit/UI/KeyboardStage/Metal/KeyboardStageTuning.swift), but the shader consumes it through saturate:

float wear = isKey
    ? saturate(input.material.x) * saturate(uniforms.tuningE.z)
    : 0.0;

saturate(uniforms.tuningE.z) clamps any value ≥ 1.0 down to 1.0, so the shipped default of 1.5 behaves identically to 1.0 — the extra headroom the tuning value implies is silently discarded. This is easy to miss: someone tuning via KEYPATH_STAGE_TUNING_FILE with wearStrength between 1.0 and, say, 3.0 would see no visual change at all above 1.0, which is surprising given the field is a plain Float with no documented [0,1] contract (unlike highlightJitter/fillLightStrength/vignetteStrength, which are not saturated and do scale linearly past 1.0).

Suggest either dropping the saturate() around tuningE.z in the shader (if values > 1 should meaningfully increase wear) or clamping/documenting wearStrength as a [0,1] knob on the Swift side so the default doesn't imply unused headroom.

No other correctness issues found; the struct layout changes (new material/tuningE/vignette fields) are consistently ordered between the Metal and Swift sides, and the entrance-duration test/doc updates match the holdDuration change.

malpern and others added 3 commits July 29, 2026 09:21
The generated reference reads as photographed hardware partly because no
two keycaps respond to light identically. The renderer's caps were
perfectly instanced: identical gloss, identical grain, highlights landing
at exactly the same spot on every cap.

- Per-key wear, seeded by key code: fingers polish high-traffic caps, so
  the space bar, home row, and modifier caps get glossier crowns, calmer
  grain, and stronger highlight response, while the number row keeps its
  factory texture.
- Per-key highlight jitter: a stable per-key seed tilts each crown
  fractionally, so highlights land slightly apart cap to cap.
- A dim cool fill from the lower left mirrors a studio rig's second
  source, lifting shadow sides with a two-temperature specular response;
  it stays a whisper in the dark hold so the room stays black.

All four behaviors ride the KeyboardStageTuning uniform block
(fillLightStrength, fillSpecularStrength, wearStrength, highlightJitter)
with live-tunable defaults; wearStrength/highlightJitter at 0 restore
the prior uniform look. Metal goldens re-recorded and compared clean;
keyboard-stage, tuning, and snapshot suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Results of a live tuning session against the reference image, run
through the new offscreen preview loop rather than screen automation:

- Add a window-space corner vignette to the composite pass (strength
  0.58, inner radius 0.33): the dark room now sinks toward the frame
  edges like the reference photograph, and the falloff relaxes to 30%
  at the settled light so the endpoint stays clean.
- Raise wear (1.5), highlight jitter (2.2), and the cool fill
  (0.95/0.15) from their conservative first-pass defaults; each cap now
  catches the light visibly apart from its neighbors.
- Add KeyboardStageTuningPreviewTests, an env-gated utility that renders
  dark/mid/settled frames through the exact Metal pipeline into a
  scratch directory honoring KEYPATH_STAGE_TUNING_FILE — deterministic
  tuning iterations with no installed app or window automation.

Metal goldens re-recorded at the new defaults and re-compared clean;
tuning, entrance, and both snapshot suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 1.5-second hold read as slightly rushed in installed review: the
light began rising before the dark room had fully registered. Hold the
still frame for 2.0 seconds after the first presented keyboard frame;
the reveal itself is unchanged. Contract tests and the plan document
follow the new boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@malpern
malpern force-pushed the claude/stage-wear-and-highlights branch from c1aa3f0 to 3653591 Compare July 29, 2026 16:21
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review findings

  1. Possible bug — wearStrength tuning knob is silently clamped to a no-op above 1.0. In KeyboardStage.metal:

    float wear = isKey
        ? saturate(input.material.x) * saturate(uniforms.tuningE.z)
        : 0.0;
    

    uniforms.tuningE.z is wearStrength, whose Swift default (KeyboardStageTuning.swift) is 1.5. Wrapping it in saturate(...) clamps it to 1.0, so the default 1.5 behaves identically to 1.0, and any tuning-file override above 1.0 has zero effect. The sibling lanes in the same tuningE vector (fillLightStrength, fillSpecularStrength, highlightJitter) are all used unclamped elsewhere in the same function, so this looks like an accidental saturate() rather than intentional clamping. Worth double-checking against the intended tuning range — if amplification above 1.0 is supposed to be usable (as the 1.5 default implies), drop the saturate around tuningE.z.

  2. Test coverage gap for new tuning surface. KeyboardStageTuningTests.swift only asserts gpuVectorE.x and .z; .y (fillSpecularStrength) and .w (highlightJitter) aren't checked, and vignetteVector isn't exercised at all. There's also no decode test confirming the six new Codable keys (fillLightStrength, fillSpecularStrength, wearStrength, highlightJitter, vignetteStrength, vignetteInnerRadius) round-trip correctly from a tuning-override JSON file, which is exactly the mechanism this PR's new KeyboardStageTuningTests/preview harness depends on.

No other correctness/reliability issues found; the GPU struct layout changes (added material/tuningE/vignette fields) are consistently ordered between the Metal and Swift sides, and the entrance-duration test updates are mechanical follow-through from the holdDuration change.

@malpern
malpern enabled auto-merge (squash) July 29, 2026 16:28
@malpern
malpern merged commit be95484 into master Jul 29, 2026
7 of 8 checks passed
@malpern
malpern deleted the claude/stage-wear-and-highlights branch July 29, 2026 16:32
malpern added a commit that referenced this pull request Jul 29, 2026
The 250ms bound measured real elapsed time and failed on a loaded CI
runner (268ms in PR #1243's build-and-test job) despite being unrelated
to the change under test. boundedCapture has no injectable clock, so
keep the real-clock measurement but widen the bound to a generous
5-second hang guard: it still fails if the timeout path hangs instead
of returning first-class timed-out evidence, without depending on
scheduler latency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
malpern added a commit that referenced this pull request Jul 29, 2026
The 250ms bound measured real elapsed time and failed on a loaded CI
runner (268ms in PR #1243's build-and-test job) despite being unrelated
to the change under test. boundedCapture has no injectable clock, so
keep the real-clock measurement but widen the bound to a generous
5-second hang guard: it still fails if the timeout path hangs instead
of returning first-class timed-out evidence, without depending on
scheduler latency.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant