UX gap sweep: action contract in CI, signed-out devices stop, dead controls wired #157

Closed
kayg wants to merge 0 commits from issue/107-ux-gaps-2 into dev
Owner

Closes #107. Replaces #152 (the forge ignores pushes to an open PR); this adds one fix: the sign-in journal entry is written in the background, which #152's CI showed delayed a sign-in enough to expire an 80 ms test session.

The full catalogue (gap → where → severity → fix) is on #107. Big gaps filed as #137 (open and restore a version) and #138 (a session that runs out should end the live socket).

The contract check (the #20 class of bug)

  • Every action's input type now gives a JSON Schema (schemars, via ActionSpec::typed), also served by GET /api/v1/actions.
  • contracts/actions.json holds them all; cargo test -p calternal contract fails when it drifts (UPDATE_CONTRACTS=1 rewrites it). The plugin list moved to crates/calternal/src/plugins.rs, so the test checks exactly what the server runs.
  • apps/web/scripts/action-types.mjs turns it into src/lib/actions.gen.ts; bun run check fails if that's stale, and action() only accepts a real action name with that action's input. src/lib/api.typetest.ts proves it bites (the #20 call account.revoke {id} is case one).
  • action() adds an idempotency key whenever the contract says the action changes things.
  • The notes.* inputs now refuse unknown fields, like the rest.
  • Against today's dev (Trash, passkeys, prefs-offline merged): no mismatches.

Fixes from the walk-through

  • Broken / data-risky: a device signed out from another device kept its live socket and kept saving edits. The socket now ends with unauthorized when its Installation is revoked or signs out, and the app shows "This device was signed out. Sign in to keep going." (also on any 401 from an action). A note switch no longer throws state_unsafe_mutation.
  • Dead ends: Settings → Writing → Show markdown is a real preference (ui.source) now. "This device" is never an empty section.
  • Errors in plain words: Devices list and Sign out, Pin, and Versions say when something failed and offer Try again, instead of saying something untrue or nothing.
  • Live: signing in records account.installation-added (in the background, so the sign-in never waits on it), and an open Devices list shows the new device.
  • Polish: this browser first in Devices; versions say "(this device)" / "(another device)"; the palette names days like the list and calls search.visit; the Today chip never wraps on a phone.

Tests

  • Rust: contract_matches_the_server, signing_a_device_out_closes_its_live_socket (server), signing_in_shows_up_live_on_another_device (auth).
  • Web: the typecheck test; flow.spec.ts now signs a third device out from Devices and checks that it stops ("This device was signed out…"), that a new sign-in shows up live, and that the Show markdown setting works.
  • Locally: clippy, all Rust tests and bun run check pass. Browser tests pass in Chromium and WebKit when run with fewer workers; under a load average of 60 to 140 on the shared machine, some editor and WebKit timing tests timed out, then passed when rerun with fewer workers.

🤖 Generated with Claude Code

Closes #107. Replaces #152 (the forge ignores pushes to an open PR); this adds one fix: the sign-in journal entry is written in the background, which #152's CI showed delayed a sign-in enough to expire an 80 ms test session. The full catalogue (gap → where → severity → fix) is on #107. Big gaps filed as #137 (open and restore a version) and #138 (a session that runs out should end the live socket). ## The contract check (the #20 class of bug) - Every action's input type now gives a JSON Schema (`schemars`, via `ActionSpec::typed`), also served by `GET /api/v1/actions`. - `contracts/actions.json` holds them all; `cargo test -p calternal contract` fails when it drifts (`UPDATE_CONTRACTS=1` rewrites it). The plugin list moved to `crates/calternal/src/plugins.rs`, so the test checks exactly what the server runs. - `apps/web/scripts/action-types.mjs` turns it into `src/lib/actions.gen.ts`; `bun run check` fails if that's stale, and `action()` only accepts a real action name with that action's input. `src/lib/api.typetest.ts` proves it bites (the #20 call `account.revoke {id}` is case one). - `action()` adds an idempotency key whenever the contract says the action changes things. - The `notes.*` inputs now refuse unknown fields, like the rest. - Against today's dev (Trash, passkeys, prefs-offline merged): no mismatches. ## Fixes from the walk-through - **Broken / data-risky:** a device signed out from another device kept its live socket and kept saving edits. The socket now ends with `unauthorized` when its Installation is revoked or signs out, and the app shows "This device was signed out. Sign in to keep going." (also on any 401 from an action). A note switch no longer throws `state_unsafe_mutation`. - **Dead ends:** Settings → Writing → Show markdown is a real preference (`ui.source`) now. "This device" is never an empty section. - **Errors in plain words:** Devices list and Sign out, Pin, and Versions say when something failed and offer Try again, instead of saying something untrue or nothing. - **Live:** signing in records `account.installation-added` (in the background, so the sign-in never waits on it), and an open Devices list shows the new device. - **Polish:** this browser first in Devices; versions say "(this device)" / "(another device)"; the palette names days like the list and calls `search.visit`; the Today chip never wraps on a phone. ## Tests - Rust: `contract_matches_the_server`, `signing_a_device_out_closes_its_live_socket` (server), `signing_in_shows_up_live_on_another_device` (auth). - Web: the typecheck test; `flow.spec.ts` now signs a third device out from Devices and checks that it stops ("This device was signed out…"), that a new sign-in shows up live, and that the Show markdown setting works. - Locally: clippy, all Rust tests and `bun run check` pass. Browser tests pass in Chromium and WebKit when run with fewer workers; under a load average of 60 to 140 on the shared machine, some editor and WebKit timing tests timed out, then passed when rerun with fewer workers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The server builds each action's input JSON Schema from its Rust type
(schemars) and contracts/actions.json holds them all; a test fails when the
file drifts. The web app turns the file into types for action(), so a call
with a wrong name or wrong input (like account.revoke with {id}) fails the
typecheck. The notes inputs now refuse unknown fields, and action() adds an
idempotency key for every action the contract says changes things.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Revoking a device (or signing out) recorded account.installation-revoked, but
the device's open websocket kept working, so it could keep typing into notes
it had open. The live socket now ends with an unauthorized error when its own
Installation is signed out, and the web app (on that error, or any 401 from an
action) shows sign-in with "This device was signed out"; what was typed stays
in the local copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
- The bar's editing state no longer throws state_unsafe_mutation when the
  editor unmounts (every note switch logged it).
- Devices: this browser first, a busy Sign out button, a plain error with
  Try again when a sign-out or the list fails (never "Only this browser").
- This device is never an empty section.
- Versions name the device (this device / another device), say when they
  couldn't load, and offer Try again.
- Pin says when it didn't stick.
- The palette names days like the list does and tells search what was opened.
- The Today chip never breaks over two lines on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Only sign-outs were journaled, so a Devices list open on one device never
showed a device that had just signed in. Signing in now records
account.installation-added (never failing the sign-in if it can't).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Settings → Writing → Show markdown works (it was a switch wired to nothing)
Some checks failed
CI / rust (pull_request) Failing after 2m3s
CI / web (pull_request) Failing after 8m16s
9ab90f2744
It's a preference now (ui.source, on every device): notes open in source
mode, and the bar or the palette still flips one note; the link says
mode=default or mode=source only when that differs from the setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Sign-in journals in the background, never delaying the sign-in
Some checks failed
CI / rust (pull_request) Successful in 2m22s
CI / web (pull_request) Failing after 8m25s
e5dd19818b
Recording account.installation-added inline made the sign-in answer wait for
a journal write, enough to push a short-lived test session past its TTL on a
busy runner. Tests that watch the live feed skip sign-in entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

Review: I read the action contract, the sign-out cut-off and the fixes.

Merged with dev:

  • SignIn.svelte: I kept the fixed 'Your Personal Cloud' line from #139 and kept this PR's 'ended' message.
  • InfoSheet.svelte: I kept dev's 'no Versions section when empty' and added this PR's error line with Try again.

Checks after the merge:

  • fmt passes and svelte-check reports 0 errors.
  • calternal, calternal-auth and calternal-notes pass except one run of fanout::a_crash_between_fan_out_and_disk_loses_nothing at load around 100. That test passed 5 of 5 alone and isn't touched by this PR.

Merging.

Review: I read the action contract, the sign-out cut-off and the fixes. **Merged with dev:** - **SignIn.svelte:** I kept the fixed 'Your Personal Cloud' line from #139 and kept this PR's 'ended' message. - **InfoSheet.svelte:** I kept dev's 'no Versions section when empty' and added this PR's error line with Try again. **Checks after the merge:** - fmt passes and svelte-check reports 0 errors. - calternal, calternal-auth and calternal-notes pass except one run of fanout::a_crash_between_fan_out_and_disk_loses_nothing at load around 100. That test passed 5 of 5 alone and isn't touched by this PR. Merging.
kayg closed this pull request 2026-10-14 08:13:35 +00:00
Some checks failed
CI / rust (pull_request) Successful in 2m22s
CI / web (pull_request) Failing after 8m25s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!157
No description provided.