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

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

Closes #107.

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, so 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. 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`, so 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
Author
Owner

Replaced by #157 (same commits plus one fix: the sign-in journal entry is written in the background).

Replaced by #157 (same commits plus one fix: the sign-in journal entry is written in the background).
kayg closed this pull request 2026-10-14 06:23:40 +00:00
Some checks failed
CI / rust (pull_request) Failing after 2m3s
CI / web (pull_request) Failing after 8m16s

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!152
No description provided.