Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
230d748
fix(home): key the tail fragment so the inbox stops remounting on vie…
scotej Jul 24, 2026
5198645
fix(friends): reseed the pending-invite clock when the list becomes n…
scotej Jul 24, 2026
d082eba
fix(session): stop counting OS-sleep as study minutes
scotej Jul 24, 2026
606f583
fix(session): end at once when every peer left deliberately
scotej Jul 24, 2026
df030f2
fix(friends): notify on a friend's first genuine arrival after launch
scotej Jul 24, 2026
c3d8b1d
fix(session): cap and sanitize the peer name from the session hello
scotej Jul 24, 2026
cdca1f0
fix(ai): stop a dead webcam from toast-storming the sample loop
scotej Jul 24, 2026
cee78ce
fix(design): make the focus ring clear WCAG 1.4.11 and measure it for…
scotej Jul 24, 2026
4ba24ee
fix(a11y): give dropdown-menu rows a highlight that paints pixels
scotej Jul 24, 2026
4b8f683
fix(a11y): wake up the dead secondary hover on Button and Badge
scotej Jul 24, 2026
381166f
fix(a11y): make the session-rail scrollers keyboard-reachable
scotej Jul 24, 2026
6500967
fix(settings): route the nav-search keywords to the panes that own th…
scotej Jul 24, 2026
7268f92
fix(settings): make "Reset shortcuts to defaults" survive a combo col…
scotej Jul 24, 2026
f262858
fix(updater): guard in-session Restart now / Check now like the updat…
scotej Jul 24, 2026
046e9e9
fix(updater): stop About claiming "you're on the latest" before any c…
scotej Jul 24, 2026
2a755af
feat(identity): name the non-wordlist words when a 24-word restore fails
scotej Jul 24, 2026
21724bc
fix(stats): anchor the Focus-over-time tooltip to its session's day
scotej Jul 24, 2026
550c1c0
feat(stats): lead the stats CSV with the headline summary tiles
scotej Jul 24, 2026
db17628
fix(sidecar): open the log file before spawning llama-server
scotej Jul 24, 2026
9f30b15
fix(sidecar): base the respawn budget on child uptime, not crash spacing
scotej Jul 24, 2026
e466f76
fix(db): point the corrupt-DB recovery dialog at the friends backup i…
scotej Jul 24, 2026
6c06950
perf(db): narrow the cross-session audit read to AI distraction rows
scotej Jul 24, 2026
8ef06b9
ci(release): stamp an incomplete draft's title before a human can pub…
scotej Jul 24, 2026
e1479f4
test(crypto): pin regression vectors for the six rendezvous derivations
scotej Jul 24, 2026
4fc732e
test(session): pin every branch of the signed-hello peerId<->pubkey gate
scotej Jul 24, 2026
d1eb474
test(friends): pin the PR-18 inbox replay guard against dual-transpor…
scotej Jul 24, 2026
bb6ec12
docs(design-system): reconcile §4 inventory with the tree and fix the…
scotej Jul 24, 2026
d75301f
docs(architecture): reconcile §11 update-check bullet and §12 capabil…
scotej Jul 24, 2026
d7f3630
docs(readme): stop the Versioning para from making a currency claim
scotej Jul 24, 2026
e55d664
fix: apply the adversarial-review findings on this branch
scotej Jul 24, 2026
0737077
docs: stage the wave-3 CHANGELOG notes and ledger rows I51–I70
scotej Jul 24, 2026
ad4599b
Merge main (PRs #81–#85) into improvement wave 3
scotej Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ on:
workflow_dispatch:

# See ci.yml's pinning note: third-party actions are SHA-pinned because this
# workflow mints the unsigned installers friends manually install. The
# publish job re-declares the contents: write it needs.
# workflow mints the unsigned installers friends manually install. The publish
# and verify-updater-manifest jobs each re-declare the contents: write they
# need, scoped to the job so the write token never widens to the whole workflow.
permissions:
contents: read

Expand Down Expand Up @@ -182,3 +183,81 @@ jobs:
# both darwin-aarch64 and windows-x86_64.
includeUpdaterJson: true
args: ${{ matrix.args }}

# X6 — a human publishes the draft, and a draft can exist with only the
# platform whose build leg survived (the other tripped on fetch-llama-server,
# a clippy break, a runner timeout — fail-fast is off above). Publishing that
# half-built draft strands every friend on the missing platform: their
# updater rejects a latest.json with no matching key. Nothing else asserts
# the manifest is complete, so this job stamps the draft's own title — where
# the publish button is — when a platform key is missing, and also catches a
# tauri-action bump regressing the unlocked read-merge-write the design leans
# on. It fails hard only on a genuinely missing platform key; its own
# gh/transport errors stay non-blocking so a chronically-red gate never
# trains the one reviewer to publish past it.
verify-updater-manifest:
name: Verify draft latest.json carries every platform
needs: publish
# !cancelled(), not always(): still run when a build leg failed, but handle
# "both legs died before any draft existed" as its own no-release branch
# below rather than reporting it as "latest.json is missing a platform".
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 10
# Scoped to this job, never the workflow. Reading a DRAFT release — and the
# draft-title edit below — needs push access: GitHub lists drafts only to
# push users and gh's by-tag draft lookup goes through the same rule, so
# contents: read would 404 on every release.
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
steps:
- name: Assert every platform is present before the draft can be published
shell: bash
run: |
set -uo pipefail

stamp_and_fail() {
echo "::error::$1"
echo "- $1" >> "$GITHUB_STEP_SUMMARY"
# Put the warning where the publish button is; the ::error:: and the
# step summary above are only visible on the already-red run page.
gh release edit "$TAG" --draft \
--title "StudyVis $TAG — INCOMPLETE, DO NOT PUBLISH" || true
exit 1
}

# No-release branch: both build legs can fail before tauri-action
# creates any draft. There is nothing to publish and nothing to
# stamp — the failed publish job already reddens the run.
if ! gh release view "$TAG" >/dev/null 2>&1; then
echo "No draft release '$TAG' was created — nothing to verify." \
| tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi

# gh transport errors (or a wholly absent asset) must not redden the
# gate; only a genuinely missing platform key does.
if ! gh release download "$TAG" --pattern latest.json --clobber; then
echo "::warning::Could not fetch latest.json for '$TAG' (gh transport error or asset absent) — skipping the completeness check." \
| tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
Comment on lines +241 to +247

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail when latest.json is absent.

An absent manifest follows the same success path as a transient gh failure, so an incomplete draft remains publishable and unstamped—the exact release state this job is meant to prevent. List assets first; fail/stamp if latest.json is missing, and keep only genuine API/download failures non-blocking.

Proposed fix
+          assets="$(gh release view "$TAG" --json assets --jq '.assets[].name')" || {
+            echo "::warning::Could not list release assets for '$TAG' — skipping the completeness check." \
+              | tee -a "$GITHUB_STEP_SUMMARY"
+            exit 0
+          }
+          if ! grep -Fxq latest.json <<<"$assets"; then
+            stamp_and_fail "Draft release '$TAG' has no latest.json. Do NOT publish — re-run the failed build leg."
+          fi
+
           if ! gh release download "$TAG" --pattern latest.json --clobber; then
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# gh transport errors (or a wholly absent asset) must not redden the
# gate; only a genuinely missing platform key does.
if ! gh release download "$TAG" --pattern latest.json --clobber; then
echo "::warning::Could not fetch latest.json for '$TAG' (gh transport error or asset absent) — skipping the completeness check." \
| tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
# gh transport errors (or a wholly absent asset) must not redden the
# gate; only a genuinely missing platform key does.
assets="$(gh release view "$TAG" --json assets --jq '.assets[].name')" || {
echo "::warning::Could not list release assets for '$TAG' — skipping the completeness check." \
| tee -a "$GITHUB_STEP_SUMMARY"
exit 0
}
if ! grep -Fxq latest.json <<<"$assets"; then
stamp_and_fail "Draft release '$TAG' has no latest.json. Do NOT publish — re-run the failed build leg."
fi
if ! gh release download "$TAG" --pattern latest.json --clobber; then
echo "::warning::Could not fetch latest.json for '$TAG' (gh transport error or asset absent) — skipping the completeness check." \
| tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 241 - 247, Update the release
completeness check around the gh release download flow to list release assets
first and distinguish an absent latest.json from genuine API or download
failures. Fail the job and apply the existing stamping behavior when the
manifest is missing, while retaining the non-blocking warning-and-exit path only
for transport or API errors.


# Assert the two base keys the plugin resolves. The file also carries
# darwin-aarch64-app and windows-x86_64-nsis, so do not assume an
# exact entry count — check membership only.
if jq -e '.platforms | has("darwin-aarch64") and has("windows-x86_64")' latest.json >/dev/null; then
echo "latest.json carries darwin-aarch64 and windows-x86_64." \
| tee -a "$GITHUB_STEP_SUMMARY"
# A prior failed run may have stamped the title; a good run clears it
# back to the name tauri-action used, or a fixed release stays
# labelled DO NOT PUBLISH.
gh release edit "$TAG" --draft --title "StudyVis $TAG" || true
exit 0
fi

keys="$(jq -c '.platforms | keys' latest.json 2>/dev/null || echo '?')"
stamp_and_fail "Draft release '$TAG' latest.json is missing a platform: has $keys, needs darwin-aarch64 and windows-x86_64. Do NOT publish — re-run the failed build leg, then re-run this workflow."
51 changes: 32 additions & 19 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ What's **broadcast in real time**: the kinds above. Peers see "Sam: ai_warning (

Audit log is also written to local SQLite per session for the post-session report and (V3) stats.

The Stats dashboard's **focus-insights** section (R7) reads the full `audit_events` table cross-session via the `audit_events_list_all` command — when-distractions-cluster timing, recurring distraction reasons, and a focused-time trend, all derived from the same `ai_warning`/`ai_alert` reasoning the single-session report already shows. The numeric stats tiles (`statsData`) remain **sessions-table-only** (they never query `audit_events`); the cross-session insight transforms live in the pure `features/stats/statsInsights.ts` seam. Strictly local — nothing here transmits.
The Stats dashboard's **focus-insights** section (R7) reads the cross-session `ai_warning`/`ai_alert` audit rows via the `audit_events_list_all` command — when-distractions-cluster timing, recurring distraction reasons, and a focused-time trend, all derived from the same `ai_warning`/`ai_alert` reasoning the single-session report already shows. The command narrows the read to those two kinds (insights ignore every other kind), so the whole table never crosses IPC. The numeric stats tiles (`statsData`) remain **sessions-table-only** (they never query `audit_events`); the cross-session insight transforms live in the pure `features/stats/statsInsights.ts` seam. Strictly local — nothing here transmits.

## 10. Pomodoro sync

Expand All @@ -557,7 +557,8 @@ studyvis/
│ │ ├─ llama-server-win-x64.exe
│ │ └─ llama-server-linux-x64
│ ├─ capabilities/
│ │ └─ default.json # Tauri 2 ACL
│ │ ├─ default.json # Tauri 2 ACL — main window
│ │ └─ ai-dialog.json # scoped ACL — floating AI dialog
│ └─ src/
│ ├─ main.rs # builder + plugin registration
│ ├─ commands/ # Tauri commands callable from JS
Expand Down Expand Up @@ -611,7 +612,7 @@ The `commands/` tree above is illustrative; the actual command modules are `iden
- **Local data management:** `sessions_delete`, `sessions_clear_all` (each tx-scoped, deleting the session row and its `audit_events` together), `audit_events_list_all` (the cross-session read backing the focus-insights view), and `system_write_text_file` (the report / audit-JSON / stats-CSV save path — no fs-plugin surface added).
- **Friends backup:** `friends_export` / `friends_import` (sealed-box to the user's own X25519 key, SVFB v1 format; import upserts on `ON CONFLICT(ed_pubkey_hex)`).
- **Lifecycle:** `session_set_active` (drives the Rust `SessionActiveFlag` for the quit-confirm path) and `app_quit` (arms the quit and exits after the in-app confirm).
- **Version check:** `system_fetch_latest_version` (a bare, unauthenticated GET behind the OFF-by-default opt-in; no identifiers, 10 s timeout).
- **Update check:** none — X6's `tauri-plugin-updater` owns this end to end (see §2 "Auto-update"); the X4 `system_fetch_latest_version` command was removed in v1.5.0. The `version_check_enabled` store key is still read (never written) as the fallback that carries an explicit X4 opt-out onto `auto_update_enabled`.
- `identity_save_keys` gained an `overwrite: bool` argument — create-new passes `false` (so a corrupt-`identity.json` load can never clobber still-valid keychain keys), and the explicit Recover/Restore path passes `true` after its own confirm.

## 12. Permissions and entitlements
Expand All @@ -635,7 +636,13 @@ Linux is not part of the V1 release matrix — V0 deferred WebKitGTK `getDisplay
- Distribution: `.AppImage` (no install, no sudo); `.deb`/`.rpm` only if there's a clear friends-need.

### Tauri capabilities
`src-tauri/capabilities/default.json` permits the specific plugins we use: shell (sidecar exec only for our bundled binaries), notification, global-shortcut, autostart, store. Permissions are scoped to the main window.

`src-tauri/capabilities/` holds two ACL files, each scoped to a single window:

- `default.json` (`windows: ["main"]`) grants `core:default`, `notification:default`, `store:default`, `dialog:default`, `deep-link:default`, `updater:default`, plus seven `core:window:*` bindings — `start-dragging` / `minimize` / `toggle-maximize` / `close` for the opt-in custom titlebar, and `set-size` / `center` / `unmaximize` for the Settings → Appearance → Window reset.
- `ai-dialog.json` (`windows: ["ai-dialog"]`) grants `core:default` + `core:window:allow-close` only, with no plugin surface. Confining the floating dialog to its own core-only capability is what keeps every plugin grant on the main window — the "scoped to the main window" invariant §12 relies on.

Plugins driven only from Rust need no ACL entry: the Tauri 2 ACL gates webview IPC, so `shell` (sidecar spawn, `commands/sidecar.rs`), `global-shortcut`, `autostart`, `opener`, and `single-instance` are registered in `lib.rs` and reached from Rust or through our own `invoke_handler` commands, none of which the ACL mediates. Correspondingly they ship no `@tauri-apps/plugin-*` JS package — package.json's five plugin packages (`deep-link`, `dialog`, `notification`, `store`, `updater`) map 1:1 to the five non-core plugin grants above.

### Always-on-top floating windows (AI text dialog)
The `Ctrl+]` AI dialog is a separate Tauri window with:
Expand Down Expand Up @@ -709,25 +716,31 @@ The `Ctrl+]` AI dialog is a separate Tauri window with:
room empties (peer count 1)
[20 s grace window (S1)]
│ │
a peer reconnects expires
│ │
▼ ▼
[media live] [auto-end: persist row,
(resume) generate report]
Report offers Rejoin (#47 B3,
auto-ends only; re-entry merges
into the same sessions row)
[tear down, return to idle]
┌─────────────────────────────┤
│ every departure explained │
│ (each peer broadcast a │
│ signed `left` first) ▼
│ [20 s grace window (S1)]
│ │ │
│ a peer reconnects expires
│ │ │
▼ ▼ ▼
[end now: persist row, [media live] [auto-end: persist row,
generate report, (resume) generate report]
reason `peer`] │
│ Report offers Rejoin (#47 B3,
│ auto-ends only; re-entry merges
│ into the same sessions row)
│ │
└──────────────────┬────────────────┘
[tear down, return to idle]
```

A deliberate local Leave skips the grace window: it persists and reports immediately with reason `user` (no Rejoin offer).

A deliberate *remote* Leave skips it too. `handleLeave` broadcasts a signed `left` audit event and awaits it before `room.leave()`; both ride the same ordered data channel, so the receiver has the peer marked as departed before trystero reports the departure. When the room empties and **every** departure since the last join was marked that way, the session ends immediately with reason `peer` — no waiting for a friend who isn't coming back, and no Rejoin button into a room nobody is in. Any unmarked departure (crash, kill, tray-quit, transport drop) keeps the full grace window and the `auto` + Rejoin path, and a peer re-invited into the still-live session clears their mark on rejoin so a later blip of theirs is debounced again.

## 14. Threat model & known limitations

| Concern | Mitigation | Residual risk |
Expand Down
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,91 @@ V3 work was drafted as v1.0.4 but shipped under the **v1.0.5** tag —
there is no v1.0.4 tag; the section below is labelled by the tag that
shipped it.)

## Unreleased — a correctness-and-accessibility maintenance pass

A verified round of fixes across sessions, friend presence, the AI focus
loop, accessibility, settings, and the updater — the third improvement
wave. Nothing changes about your identity, friends, or history.

### Fixed

- **Study time no longer counts the hours your laptop was asleep.** Ending
a session by closing the lid used to persist the entire span until wake —
a 45-minute session slept on could show up as a ~10-hour bar and a free
streak day. Elapsed time is now measured on a clock that stops while the
machine is suspended, so the recorded minutes match the minutes you were
actually there. (Sessions already recorded stay as they were — the fix
isn't retroactive.)

- **Opening Settings or starting a session no longer flickers you offline
to your friends.** A view switch was tearing down and rebuilding the
always-on presence connection, which broadcast a spurious "left" and
blanked your friends list for up to 30 seconds — occasionally firing a
phantom "came online" ping and dropping an invite that arrived in the gap.
The connection now persists across every screen change.

- **When a friend clicks Leave, the session ends right away** instead of
sitting on "Waiting for your friend to reconnect…" for 20 seconds and
then offering to rejoin a room nobody is in. A genuine connection blip
still gets the full grace window — including when another friend joins or
leaves in the meantime.

- **A friend's first arrival of the day now notifies you.** The "friend
came online" notification was suppressing every friend's first online
moment after launch — the one event it exists for.

- **The pending-invite countdown is honest on the first glance.** A row that
appeared after the app sat idle briefly showed a wildly wrong "expires in"
time for its first ten seconds.

- **A dead webcam no longer floods the session with error toasts.** If a
camera is unplugged or grabbed by another app mid-session, the AI focus
loop reports it once and quietly skips checks until it's back, instead of
a fresh toast every few seconds.

- **The keyboard focus ring is visible.** The ring that marks the focused
control fell below the contrast floor in both themes — nearly invisible in
light mode. It's now measured against the surfaces it's actually drawn on
and clears the bar.

- **Dropdown and menu rows highlight the one you're on.** The highlight
painted the same color as the menu, so keyboard and mouse users had no
cue — most visible in the in-session microphone and speaker pickers.

- **The session log and notes are reachable by keyboard.** Both scrolling
panels can now be focused and scrolled without a mouse.

- **Settings search finds what it promises.** Typing "tray", "minimize",
"capture displays", or "auto-update" landed on a pane that doesn't hold
those settings; "launch at login", "clear history", and "onboarding" found
nothing. Each now opens the pane that owns it.

- **"Reset shortcuts to defaults" always works** — a shortcut collision used
to make it silently do nothing, with no error.

- **Settings → About won't restart the app mid-session.** The Restart-now
and Check-now buttons there now defer during a live session, matching the
update banner, so a stray click can't drop you (and your friend) out of a
session. About also stops claiming "you're on the latest" before any
update check has actually run.

### Added

- **A failed 24-word recovery names the words that aren't real.** Mistype one
word restoring your identity and the error now tells you which word(s)
aren't in the wordlist, instead of rejecting all 24 at once.

- **The stats CSV export leads with your headline numbers** — total
sessions, streak, average score — which it previously omitted entirely.

- **The focus-over-time chart's tooltip shows the day** each point belongs
to, so a dip is anchorable to a date.

- **The corrupt-data recovery dialog points at the friends backup.** If
StudyVis ever has to reset unreadable local data, the dialog now tells you
to restore from a friends backup if you made one, instead of only "pair
again".

## 1.6.0 — 2026-07-21 — a searchable settings rail, lighter and faster

The first update that installs itself. Nothing changes about your
Expand Down
Loading