Commit Graph

22 Commits

Author SHA1 Message Date
funman300 18d7937b51 refactor(core): derive Copy for DrawMode; drop redundant .clone() calls (M-18)
DrawMode is a fieldless two-variant enum — it is trivially bitwise-
copyable. Adding Copy + updating choose_winnable_seed to take the value
directly eliminates 13 superfluous .clone() calls across solitaire_core,
solitaire_engine, solitaire_assetgen, and solitaire_wasm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:18:23 -07:00
funman300 f8f1f26d64 fix(input): adaptive drop zones, touch event correctness, modal lifecycle guards
H-3:  cursor_plugin drop_overlay_rect and card_centre_for_index now use
      layout.tableau_fan_frac instead of the static TABLEAU_FAN_FRAC constant,
      so drop zones match the actual card fan on portrait Android.
      Removed now-unused TABLEAU_FAN_FRAC import.

H-4:  touch_end_drag uncommitted-tap branch no longer writes StateChangedEvent.
      The mouse path (end_drag) already omits this event for uncommitted drags;
      the touch path now matches, preventing double-animation on valid taps.

H-6:  update_selection_highlight is now gated with run_if(resource_changed)
      on SelectionState | KeyboardDragState | GameStateResource, eliminating
      the unconditional every-frame despawn+respawn of highlight sprites.

H-7:  toggle_home_screen (M-key) now checks other_modal_scrims.is_empty()
      before spawning the home screen, preventing a second concurrent ModalScrim
      when another overlay is already open.

H-8:  spawn_mode_card now inserts ModalButton(ButtonVariant::Secondary) so
      paint_modal_buttons applies hover/press colour feedback on Android.

H-10: auto_resume_on_overlay excludes ForfeitConfirmScreen from its
      "other scrims" query via NonPauseFamilyScrim type alias. Opening the
      forfeit confirm no longer immediately despawns its parent pause modal.
      Also guards paused.0 assignment with an if-check to suppress spurious
      change-detection writes (L-15).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:15:15 -07:00
funman300 04f3dab563 fix(android): UX pass — pause stacking, timer, help content, achievement glyphs
BUG-1: pause_plugin — auto_resume_on_overlay system despawns PauseScreen
whenever any other ModalScrim becomes live; fixes Pause modal stacking on
top of Stats / Settings / Help / Achievements / Profile overlays opened
from the HUD menu while paused.

BUG-2: game_plugin — tick_elapsed_time skips the first delta_secs after
AppLifecycle::WillSuspend/Suspended so the Android post-resume frame spike
(equal to the full suspension duration) no longer inflates the in-game timer.

UX-2: help_plugin — Android build gets a touch-specific CONTROL_SECTIONS
(Tap / New Game / HUD buttons); desktop sections (Mouse, Keyboard drag,
Mode Launcher, Overlays) remain on non-Android builds only.

UX-3: achievement_plugin — replace \u{25CB} (○) and \u{2713} (✓) prefixes
with ASCII "- " / "+ "; both Geometric Shapes codepoints are absent from
FiraMono and rendered as the fallback letter "o".

Phase 8 work from previous session (already compiled, not yet committed):
hud_plugin — HUD visual hierarchy (Undo/Pause bright, nav buttons dim);
  menu popover — Help + Game Modes entries added (7 items total).
card_plugin — stock badge drops "·" prefix, shows plain count.
pause_plugin — Draw Mode segmented control (Draw 1 / Draw 3 explicit buttons).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:02:39 -07:00
funman300 d204662415 fix(android): close HUD popovers on Escape instead of opening Pause
When the Menu or Modes popover was open, pressing Escape (Android back)
fired the Pause system instead of closing the popover, because both
systems listened to the same key with no coordination.

Fix:
- Add HudPopoverOpen marker to both popover entities on spawn.
- Add close_menu/modes_popover_on_escape systems in HudPlugin that
  despawn the popover + backdrop when Escape is pressed.
- Guard toggle_pause with an open_hud_popovers query: bail if any
  HudPopoverOpen entity exists, preventing Pause from stacking behind
  the closing popover.
- Init ButtonInput<KeyCode> in HudPlugin::build() so the new systems
  work under MinimalPlugins in tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 19:10:27 -07:00
funman300 90e24d9711 feat(replay): wire ESC accelerator for stop, gate pause modal
ESC during an active replay now stops it (mirrors the existing
Stop button click). UI-first contract from CLAUDE.md §3.3 holds
for the keyboard accelerator: every keybind the footer surfaces
points at a wired action.

Cross-plugin coordination: pause_plugin's `toggle_pause` already
listens for ESC and would otherwise open the pause modal on the
same press. Resolved by adding a fourth defer-if check to the
existing modal-stack pattern in `toggle_pause` —
`replay_state.is_some_and(|s| s.is_playing())` slots in right
after `other_modal_scrims` and before `selection`. Symmetric
shape to the existing forfeit / modal-scrim / selection /
game-over / drag gates.

Footer hint extended from `[SPACE] pause/resume` to
`[SPACE] pause/resume · [ESC] stop` in lockstep — the
"only-wired-keybinds" discipline holds.

3 new tests:
- esc_keyboard_stops_active_replay (positive: Esc → Inactive,
  overlay despawns next frame)
- esc_keyboard_is_noop_when_not_playing (negative: doesn't fire
  on Inactive state, lets global Esc listeners own those frames)
- keybind_footer_hint_lists_space_and_esc (footer text contains
  both keybinds)

Plus updated helper-pin test for the new hint string. Existing
pause_plugin tests unaffected (they don't insert a
ReplayPlaybackState resource so the new gate is a no-op for
them).

Tests: 1240 → 1243 (+3). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:06:02 -07:00
funman300 08b006ff30 fix(engine): Esc on a modal no longer also opens Pause underneath
A single Esc press while the Confirm New Game / Restore / Home /
Onboarding / Settings modals were open would both close the modal
(via its own input handler) and spawn the Pause overlay on top in
the same frame, dumping the player on a screen they didn't ask for.

toggle_pause now skips when any non-Pause `ModalScrim` is in the
world. The HUD-button path is gated too — clicking Pause while
another modal is up is almost always an accident.

The four modal queries are bundled into a `PauseModalQueries`
SystemParam to stay under Bevy's 16-parameter cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:20:39 +00:00
funman300 0c86cac2d5 feat(engine): unify destructive-confirm verbs — drop "Yes," prefix
Both confirm modals previously used a "Yes, <verb>" pattern that read
like a question-and-answer dialog ("Are you sure? Yes, forfeit"); the
canonical UX pattern for a destructive confirm is just the bare verb.

The Confirm New Game modal's primary button is now "New game" instead
of "Yes, abandon" — matching the verb the user originally clicked
and framing the action positively rather than as a loss.

The Forfeit Confirm modal's primary button is now "Forfeit" instead
of "Yes, forfeit" — same pattern, less ceremony.

The Pause menu's own Resume / Forfeit buttons are unchanged: it's an
action menu, not a destructive confirm, and bare verbs are already
correct there.

Two doc comments and the ui_modal.rs spawn_modal_button example
docstring are updated to reflect the new copy. Marker symbol names
(ConfirmYesButton, ForfeitConfirmButton) are kept to avoid
unnecessary churn — the rename would ripple into mouse-input handlers
without a matching user-visible benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:45:19 +00:00
funman300 cb93bd9265 fix(engine): pin modals via GlobalZIndex and surface forfeit-no-op toast
CI / Test & Lint (push) Failing after 28s
CI / Release Build (push) Has been skipped
Two fixes the smoke test surfaced:

1. The forfeit-confirm modal at `Z_PAUSE_DIALOG` (225) was invisible
   behind the pause card at `Z_PAUSE` (220). In Bevy 0.18, root-level
   UI nodes don't reliably sort across stacking contexts via plain
   `ZIndex` alone, so `spawn_modal` now adds `GlobalZIndex(z_panel)`
   alongside the existing `ZIndex(z_panel)`. Every overlay built on
   `ui_modal` (pause, forfeit-confirm, confirm-new-game, help, home,
   leaderboard, profile, achievements, stats, game-over) inherits the
   fix.

2. `handle_forfeit_request` no longer silently drops the request when
   `move_count == 0` — pressing G or clicking the pause modal's
   Forfeit button on a freshly-dealt game now opens the confirm modal,
   and the only short-circuit is "game is already won", which now
   fires an `InfoToastEvent` ("No game to forfeit") so the player
   gets feedback. The `move_count > 0` half of the gate was the
   reason a fresh-deal G press appeared to do nothing.

The G-key gate in `handle_keyboard_forfeit` is simplified to just
"not paused"; the rest of the forfeit-eligibility check moves into
`handle_forfeit_request` so it can surface the toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 02:35:52 +00:00
funman300 6723416a55 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
CI / Test & Lint (push) Failing after 25s
CI / Release Build (push) Has been skipped
Pause overlay drops its bespoke full-screen layout and is rebuilt on
the standard `ui_modal` scaffold: uniform scrim, centred card, real
Resume (Primary, Esc) and Forfeit (Tertiary, G) action buttons. The
Draw Mode row stays inline in the body so the existing toggle still
fires `SettingsChangedEvent`.

The G-key double-press toast countdown is replaced with a real
modal: `G` (or clicking Forfeit on Pause) fires the new
`ForfeitRequestEvent`, which `PausePlugin` answers by spawning
`ForfeitConfirmScreen` at `Z_PAUSE_DIALOG` (above pause). The modal
exposes Cancel + "Yes, forfeit" buttons plus Y/Enter/N/Esc
accelerators; confirmation despawns both modals, clears
`PausedResource`, and fires `ForfeitEvent` for `StatsPlugin`.

`toggle_pause` now early-returns when a forfeit modal is visible (and
runs `.before(handle_forfeit_keyboard)`) so an Esc that closes the
forfeit modal doesn't also re-open pause in the same frame.

The legacy `forfeit_countdown` field, `FORFEIT_CONFIRM_WINDOW`
constant, and the six pure-function countdown tests are removed; new
tests cover the modal-spawn / confirm / cancel paths and the active-
game predicate that still gates the G hotkey.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 02:15:47 +00:00
funman300 97f38085e3 feat(engine): add Undo, Pause, Help UI buttons in HUD action bar
Continues the UI-first pass started by the New Game button. Per the
design principle in CLAUDE.md / ARCHITECTURE.md §1, every player action
must be reachable from a visible UI control with the keyboard shortcut
as an optional accelerator. Refactor the single New Game button into a
flex-row "action bar" anchored top-right with four buttons: Undo,
Pause, Help, New Game (left → right; New Game rightmost as the most
consequential action).

Plumbing:
- New `PauseRequestEvent` and `HelpRequestEvent` in events.rs.
- pause_plugin::toggle_pause reads either Esc or PauseRequestEvent so
  the button and the keyboard accelerator drive the same code path
  (with the existing drag / game-over / selection guards).
- help_plugin::toggle_help_screen reads either F1 or HelpRequestEvent;
  also fix the stale module-doc claim that H toggles help (it's F1 —
  H is bound to hint cycle in input_plugin).
- hud_plugin now spawns four ActionButton-marked buttons via a
  ChildSpawnerCommands helper, with one click handler per button
  firing its respective request event. A single
  paint_action_buttons system covers hover/pressed colour for all of
  them via the shared ActionButton marker. The click handlers
  defensively re-register their request events so the plugin works in
  isolation under MinimalPlugins (tests). add_message is idempotent.
- ARCHITECTURE.md HudPlugin row updated to call out the action bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:38:54 +00:00
funman300 7cda2a9f1a fix(engine): resolve all clippy warnings introduced by PNG asset pipeline
CI / Test & Lint (push) Failing after 1m34s
CI / Release Build (push) Has been skipped
- Collapse nested-if patterns into let-chains across 13 plugins (42 instances)
- Add #[allow(clippy::too_many_arguments)] to 5 Bevy systems in card_plugin
  and input_plugin where ECS parameter count exceeds the lint threshold
- Gate Theme import in table_plugin under #[cfg(test)] — only used by
  test-only colour helpers; removing the unconditional import silences the
  unused-import lint without breaking the test suite
- Wrap ButtonInput<MouseButton> in Option<> in update_input_platform so that
  tests using MinimalPlugins (no InputPlugin) no longer panic on startup

All 789 tests pass; cargo clippy --workspace -- -D warnings is clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 03:35:41 +00:00
funman300 1ec2593137 fix(engine): resolve input coordination bugs in selection/pause/keyboard
- SelectionPlugin: add clear_selection_on_state_change system so undo/move/reject
  never leave a stale selection pointing at the wrong card
- SelectionPlugin: expose SelectionKeySet system set for cross-plugin ordering
- PausePlugin: skip Escape→pause when a card is keyboard-selected; toggle_pause
  now runs before SelectionKeySet so it reads SelectionState before it is cleared
- InputPlugin: guard Space→DrawRequestEvent when SelectionState has an active pile
  so Space executes a card move instead of also drawing from stock
- window: enforce 800×600 minimum via WindowResizeConstraints
- game_state: add precondition doc to next_auto_complete_move (waste exclusion)
- card_plugin: 12 unit tests for constants, face_colour, label_visibility, label_for
- pause_plugin: add paused_resource_default and draw_mode_label exhaustiveness tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:13:10 +00:00
funman300 ffc79447d4 fix+refactor+docs: P0–P3 todo list items
P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
  (all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
  on normal draws; add move_count_increments_on_recycle regression test

P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
  (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)

P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
  handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
  new_game_confirm_window_is_positive

P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
  on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing

P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order

P3 — documentation:
- Add struct-level ///  to 12 Plugin structs (ChallengePlugin, CursorPlugin,
  AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
  HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win

card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:02:52 +00:00
funman300 21d0c289b5 chore(deps): migrate to Bevy 0.18
- BorderRadius is no longer a Component; moved into Node.border_radius
  field at all 15 spawn sites across 6 plugin files
- Events<T> renamed to Messages<T> in test code (12 files)
- KeyboardEvents SystemParam renamed to KeyboardMessages to match the
  MessageWriter rename done in the 0.17 hop
- WindowResolution::from((f32,f32)) removed; use (u32,u32) tuple in main.rs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:48:41 -07:00
funman300 648cd44387 chore(deps): migrate to Bevy 0.17
- Event/EventReader/EventWriter renamed to Message/MessageReader/MessageWriter
- add_event → add_message for all 67 call sites
- ScrollPosition changed to tuple struct ScrollPosition(Vec2)
- CursorIcon import moved from bevy::winit::cursor to bevy::window
- WindowResolution::from((f32,f32)) removed — use (u32,u32) tuple
- World::send_event → World::write_message in test code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:04:44 -07:00
funman300 c8553dc8c5 chore(deps): migrate to Bevy 0.16, axum 0.8, and other package updates
- Bump bevy 0.15 → 0.16; fixes all breaking API changes:
  ChildBuilder → ChildSpawnerCommands, Parent → ChildOf,
  despawn_descendants → despawn_related::<Children>(),
  despawn_recursive → despawn (now recursive by default),
  EventWriter::send → write, Query::{get_single,get_single_mut}
  → {single,single_mut}, ChildOf::get → parent()
- Bump axum 0.7 → 0.8; remove axum::async_trait from FromRequestParts
- Bump tower_governor 0.4 → 0.8; fix GovernorLayer::new() API
- Bump jsonwebtoken 9 → 10 with rust_crypto feature only
- Bump thiserror 1 → 2, dirs 5 → 6, bcrypt 0.15 → 0.19,
  reqwest 0.12 → 0.13 (rustls feature rename)
- Regenerate .sqlx offline cache for sqlx compile-time query checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:31:12 -07:00
funman300 03227f8c77 feat(engine): playability improvements — rounds 7–9 (#40–#64)
Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close

Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)

Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 02:35:15 +00:00
funman300 f32e53dd0b feat(engine): shake/settle/deal animations (#54, #55, #69)
Add FeedbackAnimPlugin with three card feedback animations:
- #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting
  destination pile cards; 0.3 s damped sine wave
- #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent);
  1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards
- #69 Deal animation: slides each card from stock position to its deal
  position on NewGameRequestEvent (move_count == 0), using existing
  CardAnim with 0.04 s per-card stagger

Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay
are public and covered by 6 unit tests. Fix pre-existing compile/clippy
errors: stubbed handle_confirm_input/handle_game_over_input, removed dead
CycleCardBack/CycleBackground variants, annotated ambient_handle field,
and fixed draw_mode.clone() in pause_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:55:24 +00:00
funman300 c3ee7c45a7 feat(engine): card visual improvements — flip animation, foundation/tableau placeholders, drag shadow
Task #34: CardFlipAnim component + start_flip_anim/tick_flip_anim systems animate revealed
cards by squashing scale.x to 0 then expanding back to 1 (2×0.08 s). Skipped at Instant speed.

Task #35: spawn_pile_markers now adds a Text2d child (S/H/D/C, 45% alpha) on Foundation
markers so the suit is visible while the pile is empty.

Task #43: Tableau pile markers get a "K" Text2d child (35% alpha) indicating only Kings land
on empty columns.

Task #38: update_drag_shadow system maintains a single ShadowEntity while dragging — a
card_w+8 × card_h+8 dark semi-transparent sprite at z−1 behind the top dragged card.

Also fixed pre-existing clippy/compiler errors in hud_plugin, pause_plugin, stats_plugin,
cursor_plugin, and settings_plugin (missing imports, too-many-arguments, doc formatting).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:03:59 +00:00
root bf150f11f1 test(engine): add boundary tests for format_secs and pause toggle cycle
leaderboard_plugin: format_secs was missing the 60-second crossing
boundary (1:00 vs 60s), the zero case (0s), and leading-zero padding
verification (1:05 not 1:5).

pause_plugin: added third-press test confirming the Esc toggle is
symmetric across multiple pause/resume cycles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 04:06:43 +00:00
root 00f0383867 feat(engine): in-progress game state persistence
Save game_state.json on app exit and on pause open so players can
resume interrupted sessions. Delete the file on win, loss, or new-game
start. Restore the saved game on launch if it exists and isn't won.

- solitaire_core: add pile_map_serde module so HashMap<PileType,Pile>
  round-trips through JSON (serialized as Vec of pairs)
- solitaire_data: add game_state_file_path, load_game_state_from,
  save_game_state_to, delete_game_state_at with 8 new unit tests
- solitaire_engine/GamePlugin: restore saved game on startup, expose
  GameStatePath resource, save on AppExit, delete on new-game and win
- solitaire_engine/PausePlugin: save on pause open (guards against
  OS-level kills while the overlay is showing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:17:47 +00:00
funman300 b720588687 feat(engine): MoveRejectedEvent + PausePlugin (Esc)
- New MoveRejectedEvent fires from end_drag when the cursor is over
  a real pile but the placement is illegal. AudioPlugin plays
  card_invalid.wav on it.
- New PausePlugin + PausedResource: Esc toggles a full-window
  overlay and the flag. tick_elapsed_time and advance_time_attack
  skip work while paused. Help cheat sheet updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:56:35 -07:00