Photos: Immich importer (later) #34

Open
opened 2026-09-24 14:45:12 +00:00 by kayg · 22 comments
Owner

Owner: no importers for now (round 12), but the owner fully migrated to Immich, so this is the future migration path. Import originals + albums + favourites + descriptions + (later) face names from an Immich instance via its API into Photos/ (P2 layout) and .calternal/albums/. Deferred; do not start until the owner says so.

Context for the owning job

  • Repo: kayg/calternal (~/Developer/calternal). Read CLAUDE.md, CONTEXT.md and docs/DESIGN.md first; this issue's section is cited below.
  • Owner rules that always apply: file over app (plain files are the truth, the DB is an index); the server is the single writer; data loss is unacceptable; performance first, never at the cost of finesse; UI is the calternal.js design system (copy components verbatim, compare side by side with calternal.js reference screenshots; Claude does visual review); never ship sample/mock data; atomic commits; adversarial testing after API work; good enough, not perfect (merge blockers: crash/DoS, data loss, security, sync collisions).
  • Comment on this issue when you start (branch, base SHA), on each finding, when blocked, and when finished (head SHA + gate output). Never close it.
Owner: no importers for now (round 12), but the owner fully migrated to Immich, so this is the future migration path. Import originals + albums + favourites + descriptions + (later) face names from an Immich instance via its API into `Photos/` (P2 layout) and `.calternal/albums/`. Deferred; do not start until the owner says so. ## Context for the owning job - Repo: kayg/calternal (~/Developer/calternal). Read CLAUDE.md, CONTEXT.md and docs/DESIGN.md first; this issue's section is cited below. - Owner rules that always apply: file over app (plain files are the truth, the DB is an index); the server is the single writer; data loss is unacceptable; performance first, never at the cost of finesse; UI is the calternal.js design system (copy components verbatim, compare side by side with calternal.js reference screenshots; Claude does visual review); never ship sample/mock data; atomic commits; adversarial testing after API work; good enough, not perfect (merge blockers: crash/DoS, data loss, security, sync collisions). - Comment on this issue when you start (branch, base SHA), on each finding, when blocked, and when finished (head SHA + gate output). Never close it.
Author
Owner

Starting tray-order work on job/tray-order at base SHA 268b657451808355c1eecd32bf1c808ac079aa71 (local branch is 18 commits behind origin/dev, currently 2bd6890290571642c49def7f4bf0496ae0932655). The job prompt assigns the mode tray order and Money default behavior to issue #34, but the current issue body describes a deferred Immich importer. I will follow the explicit job prompt and report this tracker mismatch with the final result.

Starting tray-order work on `job/tray-order` at base SHA `268b657451808355c1eecd32bf1c808ac079aa71` (local branch is 18 commits behind `origin/dev`, currently `2bd6890290571642c49def7f4bf0496ae0932655`). The job prompt assigns the mode tray order and Money default behavior to issue #34, but the current issue body describes a deferred Immich importer. I will follow the explicit job prompt and report this tracker mismatch with the final result.
Author
Owner

Finding from the assigned tray-order work: the existing client registry in apps/web/src/lib/navigation.ts omitted the compiled Notes Plugin and used the previous Calendar/Files/Photos/Mail/Analytics/Money/Ask order. The existing normalizer also rewrote a saved notes mode to calendar. The Money Plugin manifest keeps the Instance enabled, but PluginState::is_user_enabled defaults every toggleable Plugin to on. I am correcting these behaviors to match the job prompt and DESIGN §34. The issue body is still the deferred Immich importer, so this finding is attached here only because the job prompt names #34.

Finding from the assigned tray-order work: the existing client registry in `apps/web/src/lib/navigation.ts` omitted the compiled Notes Plugin and used the previous Calendar/Files/Photos/Mail/Analytics/Money/Ask order. The existing normalizer also rewrote a saved `notes` mode to `calendar`. The Money Plugin manifest keeps the Instance enabled, but `PluginState::is_user_enabled` defaults every toggleable Plugin to on. I am correcting these behaviors to match the job prompt and DESIGN §34. The issue body is still the deferred Immich importer, so this finding is attached here only because the job prompt names #34.
Author
Owner

DESIGN §34 did not define the storage for per-User tray order. Decision: store mode_order in the User's existing .calternal/settings.json through the checked User-settings writer, with an account-scoped /api/v1/preferences/mode-order endpoint. The browser keeps a first-paint cache tagged with the User ID. It imports the old Installation order once for the first User who signs in; later Users use their own server value. Keep valid IDs for absent Plugins in place so a Plugin can return to its saved slot. This adds no separate order table.

DESIGN §34 did not define the storage for per-User tray order. Decision: store `mode_order` in the User's existing `.calternal/settings.json` through the checked User-settings writer, with an account-scoped `/api/v1/preferences/mode-order` endpoint. The browser keeps a first-paint cache tagged with the User ID. It imports the old Installation order once for the first User who signs in; later Users use their own server value. Keep valid IDs for absent Plugins in place so a Plugin can return to its saved slot. This adds no separate order table.
Author
Owner

Money migration detail not stated in DESIGN: a Money/ folder creates an enabled User override only when no override already exists. An explicit User choice to hide Money wins, so startup does not turn it back on. This keeps existing budget access by default while respecting a saved opt-out.

Money migration detail not stated in DESIGN: a `Money/` folder creates an enabled User override only when no override already exists. An explicit User choice to hide Money wins, so startup does not turn it back on. This keeps existing budget access by default while respecting a saved opt-out.
Author
Owner

Full web gate finding (2026-09-30): bun run test completed with Test Files 4 failed | 126 passed (130), Tests 5 failed | 791 passed (796), and Errors 7 errors. The five failures timed out at 5 seconds in src/lib/calendar/TimeGrid.svelte.test.ts (2), src/lib/components/KeyboardShortcutsCard.svelte.test.ts, src/lib/components/analytics/RangeBar.svelte.test.ts, and src/routes/settings/mail/MailSection.svelte.test.ts. Seven Vitest workers also failed to start before their timeout. No expectations were changed; these failures are outside this job's changed code.

Full web gate finding (2026-09-30): `bun run test` completed with `Test Files 4 failed | 126 passed (130)`, `Tests 5 failed | 791 passed (796)`, and `Errors 7 errors`. The five failures timed out at 5 seconds in `src/lib/calendar/TimeGrid.svelte.test.ts` (2), `src/lib/components/KeyboardShortcutsCard.svelte.test.ts`, `src/lib/components/analytics/RangeBar.svelte.test.ts`, and `src/routes/settings/mail/MailSection.svelte.test.ts`. Seven Vitest workers also failed to start before their timeout. No expectations were changed; these failures are outside this job's changed code.
Author
Owner

Finding from the real production tray run: for a cold mode, pointerdown moved the active label before pointerup. The browser trace recorded pointerdown on tab-files and pointerup on tab-photos, so Files did not activate. I changed the app to warm route/data on pointerdown while keeping the selected tab stable until activation. I am rebuilding the embedded web bundle before verifying this fix.

Finding from the real production tray run: for a cold mode, pointerdown moved the active label before pointerup. The browser trace recorded pointerdown on `tab-files` and pointerup on `tab-photos`, so Files did not activate. I changed the app to warm route/data on pointerdown while keeping the selected tab stable until activation. I am rebuilding the embedded web bundle before verifying this fix.
Author
Owner

The production bundle now passes the Files, Calendar and Photos pointer route checks. The next run found that Ask is not enabled for a newly registered User in this test Instance, so the tray correctly omits it; the test was still trying to click Ask unconditionally. I am making that route check conditional on the live tray, while the order assertion already compares against the live Plugin catalog.

The production bundle now passes the Files, Calendar and Photos pointer route checks. The next run found that Ask is not enabled for a newly registered User in this test Instance, so the tray correctly omits it; the test was still trying to click Ask unconditionally. I am making that route check conditional on the live tray, while the order assertion already compares against the live Plugin catalog.
Author
Owner

The live catalog reported seven enabled shell modes, while trayModeIds truncated the track to five. The tray e2e caught this because Analytics and Ask were in the catalog but absent from the rendered mode list. That cap conflicts with “3 visible, the rest scroll”; I am removing it so the existing three-item scroller contains every enabled mode.

The live catalog reported seven enabled shell modes, while `trayModeIds` truncated the track to five. The tray e2e caught this because Analytics and Ask were in the catalog but absent from the rendered mode list. That cap conflicts with “3 visible, the rest scroll”; I am removing it so the existing three-item scroller contains every enabled mode.
Author
Owner

Production tray findings (#34, DESIGN §34): the real browser measured only two fully visible tabs at 1440 px after the selected Calendar label settled. The track had a 190 px viewport while Calendar, Notes, and Files occupied 109, 45, and 49 px. ResizeObserver only refreshed fades, so the settled viewport kept an earlier width. I now recalculate the three-item window after settled size changes and added a unit test. The latest production screenshot matrix shows three tabs at 390, 820, and 1440 px in both themes; offscreen Photos also becomes fully visible after selection.

A separate 200 ms RTT run measured the cold Files loading view at 139 ms (frame 2); another run passed that check. I left the existing 100 ms assertion unchanged and recorded this as shared-host timing evidence.

Production tray findings (#34, DESIGN §34): the real browser measured only two fully visible tabs at 1440 px after the selected Calendar label settled. The track had a 190 px viewport while Calendar, Notes, and Files occupied 109, 45, and 49 px. ResizeObserver only refreshed fades, so the settled viewport kept an earlier width. I now recalculate the three-item window after settled size changes and added a unit test. The latest production screenshot matrix shows three tabs at 390, 820, and 1440 px in both themes; offscreen Photos also becomes fully visible after selection. A separate 200 ms RTT run measured the cold Files loading view at 139 ms (frame 2); another run passed that check. I left the existing 100 ms assertion unchanged and recorded this as shared-host timing evidence.
Author
Owner

The Money production E2E found that a new User's /money request was redirected to Calendar by routes/money/+layout.svelte, even though the Plugin API correctly reported Instance enabled and User disabled. That prevented the requested opt-in empty state. The route gate now keeps /money reachable and still redirects disabled Users away from nested budget routes. After rebuilding the production bundle/server, the real-server flow passed: User default off, Settings on/off, Enable Money from the empty state, Instance-off state, and budget/category/account/transaction writes. It captured 48 screenshots in light and dark at 390, 820, and 1440 px.

The Money production E2E found that a new User's `/money` request was redirected to Calendar by `routes/money/+layout.svelte`, even though the Plugin API correctly reported Instance enabled and User disabled. That prevented the requested opt-in empty state. The route gate now keeps `/money` reachable and still redirects disabled Users away from nested budget routes. After rebuilding the production bundle/server, the real-server flow passed: User default off, Settings on/off, Enable Money from the empty state, Instance-off state, and budget/category/account/transaction writes. It captured 48 screenshots in light and dark at 390, 820, and 1440 px.
Author
Owner

E2E evidence (2026-09-30): the production tray flow passed its enabled-mode order and responsive tab assertions at 390, 820 and 1440 px. The refresh attaches 54 screenshots under tray-responsive-* and money-responsive-*; the Money captures cover the default-off and opt-in states, budget and register screens, quick entry, and Instance-off state in both themes.

The existing cold Files timing check still uses its 100 ms limit. Separate runs measured 113.2 ms and 342.5 ms at frame 1; I left the assertion unchanged. The Money E2E also exposed a test race: the API returned the saved User setting before the Settings switch refreshed. The test now waits for the rendered switch to match the unchanged API expectation.

E2E evidence (2026-09-30): the production tray flow passed its enabled-mode order and responsive tab assertions at 390, 820 and 1440 px. The refresh attaches 54 screenshots under `tray-responsive-*` and `money-responsive-*`; the Money captures cover the default-off and opt-in states, budget and register screens, quick entry, and Instance-off state in both themes. The existing cold Files timing check still uses its 100 ms limit. Separate runs measured 113.2 ms and 342.5 ms at frame 1; I left the assertion unchanged. The Money E2E also exposed a test race: the API returned the saved User setting before the Settings switch refreshed. The test now waits for the rendered switch to match the unchanged API expectation.
Author
Owner

Focused real-server reproduction for the Tags phase in the adversarial round:

  • Creating the tagged Note, PDF, Photo, and assignments succeeds; rename preview returns 200.
  • POST /api/v1/tags/rename closes without a response and terminates calternal-server.
  • The retained server log reports: thread 'tokio-rt-worker' (...) has overflowed its stack followed by fatal runtime error: stack overflow, aborting.
  • Reproduced on a fresh adversarial server with only the tag rename fixture. This is a crash, not a SLOW result.

I am tracing the rename stages before making any change outside the tray-order files. The focused probe and complete server log are local under target/tmp; no credentials are included here.

Focused real-server reproduction for the Tags phase in the adversarial round: - Creating the tagged Note, PDF, Photo, and assignments succeeds; rename preview returns 200. - `POST /api/v1/tags/rename` closes without a response and terminates `calternal-server`. - The retained server log reports: `thread 'tokio-rt-worker' (...) has overflowed its stack` followed by `fatal runtime error: stack overflow, aborting`. - Reproduced on a fresh adversarial server with only the tag rename fixture. This is a crash, not a SLOW result. I am tracing the rename stages before making any change outside the tray-order files. The focused probe and complete server log are local under `target/tmp`; no credentials are included here.
Author
Owner

GDB follow-up for the focused reproduction:

  • SIGSEGV is raised on a tokio-rt-worker while constructing blake3::Hasher in calternal-fs/src/write.rs:195.
  • The caller chain is Root::replace_if → write_folder_metadata (calternal-tags/src/source.rs:514) → rewrite_tag_in_folder_metadata (source.rs:127) → rename::apply (calternal-tags/src/rename.rs:114) → rename (rename.rs:72) → the rename route handler.
  • The existing Tags integration test runs this path on a thread with a 4 MiB stack. The production request runs on a Tokio worker and exhausts its stack on this write.

This finding is outside the tray-owned code. I have not changed Tags or filesystem write behavior in this job. The earlier complete API adversarial run and the focused real-server reproduction both hit the crash.

GDB follow-up for the focused reproduction: - SIGSEGV is raised on a `tokio-rt-worker` while constructing `blake3::Hasher` in `calternal-fs/src/write.rs:195`. - The caller chain is `Root::replace_if` → `write_folder_metadata` (`calternal-tags/src/source.rs:514`) → `rewrite_tag_in_folder_metadata` (`source.rs:127`) → `rename::apply` (`calternal-tags/src/rename.rs:114`) → `rename` (`rename.rs:72`) → the rename route handler. - The existing Tags integration test runs this path on a thread with a 4 MiB stack. The production request runs on a Tokio worker and exhausts its stack on this write. This finding is outside the tray-owned code. I have not changed Tags or filesystem write behavior in this job. The earlier complete API adversarial run and the focused real-server reproduction both hit the crash.
Author
Owner

Finished

Head: e3e2936f1ac5f4bdd8745161df73e1fdac59e5a0

Implemented the single canonical mode order and synced a User's saved order across Installations. The tray filters absent Plugins and keeps all enabled modes reachable: three visible tabs on phones and up to five on wider screens. Money remains enabled for the Instance, starts disabled for new Users, and has a real opt-in empty state in Settings and /money. A one-time migration enables Money for existing Users with a Money/ folder while saved User choices take precedence.

Changed files cover the web mode registry, settings store, app shell, tab components and tests; the per-User preference API and OpenAPI client; Plugin state and Money defaults; the Money route, settings toggle and production e2e; and the adversarial API/authz probes. The latest merge also brings the current DESIGN §34 decisions into this branch.

Uploaded 54 refreshed real-production screenshots to this issue: tray-responsive-* and money-responsive-*, covering 390, 820 and 1440 px in both themes. No screenshot files are committed.

Gates

cargo fmt --check exited 0 with no output.

bun run check
$ node scripts/check-type-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
Text sizes and UI shape values use shared role tokens.
Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/tray-order/apps/web
Getting Svelte diagnostics...

svelte-check found 0 errors and 0 warnings
bun run test
 Test Files  137 passed (137)
      Tests  894 passed (894)
   Start at  14:26:52
   Duration  165.36s (transform 57%, environment 15%, import 13%, tests 8%, setup 8%)
cargo clippy -p calternal-plugin --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 13m 04s
cargo test -p calternal-plugin
 test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.76s

cargo clippy -p calternal-plugin-money --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 50.21s
cargo test -p calternal-plugin-money
 test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.93s

cargo clippy -p calternal-server --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 13m 29s
cargo test -p calternal-server
 test result: ok. 89 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 17.99s

cargo clean removed 15,247 files / 7.8 GiB. Removed apps/web/build, apps/web/.svelte-kit/output and apps/web/target. Worktree is clean.

Decisions

  • Applied DESIGN §34's three-tab phone and five-tab wider-screen behavior at the shell breakpoint (390 phone, 820 tablet, 1440 desktop); every enabled mode stays in the scroll track and selection scrolls into view.
  • Kept absent Plugin slots in saved User order and placed Ask last in the canonical registry.
  • Applied the Money legacy migration by checking for a Money/ folder; explicit saved User state wins.

Known gaps

The full local API adversarial round and a focused real-server reproduction expose a blocking crash in the existing Tags rename path. GDB shows SIGSEGV in blake3::Hasher::new while calternal-fs::Root::replace_if writes rewritten folder metadata from calternal-tags::rename. The focused run succeeds through rename preview, then POST /api/v1/tags/rename closes the connection and aborts the server with a Tokio worker stack overflow. I posted the call chain and evidence above. This behavior belongs to Tags and filesystem crates, outside the tray-owned change, so I did not alter either crate here; the adversarial merge gate remains unresolved and needs owner follow-up.

The cold Files activation timing probe measured 113.2 ms and 342.5 ms at frame one against its existing 100 ms threshold. These are SLOW-only observations; the assertion remains unchanged.

## Finished Head: `e3e2936f1ac5f4bdd8745161df73e1fdac59e5a0` Implemented the single canonical mode order and synced a User's saved order across Installations. The tray filters absent Plugins and keeps all enabled modes reachable: three visible tabs on phones and up to five on wider screens. Money remains enabled for the Instance, starts disabled for new Users, and has a real opt-in empty state in Settings and `/money`. A one-time migration enables Money for existing Users with a `Money/` folder while saved User choices take precedence. Changed files cover the web mode registry, settings store, app shell, tab components and tests; the per-User preference API and OpenAPI client; Plugin state and Money defaults; the Money route, settings toggle and production e2e; and the adversarial API/authz probes. The latest merge also brings the current DESIGN §34 decisions into this branch. Uploaded 54 refreshed real-production screenshots to this issue: `tray-responsive-*` and `money-responsive-*`, covering 390, 820 and 1440 px in both themes. No screenshot files are committed. ## Gates `cargo fmt --check` exited 0 with no output. ```text bun run check $ node scripts/check-type-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json Text sizes and UI shape values use shared role tokens. Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/tray-order/apps/web Getting Svelte diagnostics... svelte-check found 0 errors and 0 warnings ``` ```text bun run test Test Files 137 passed (137) Tests 894 passed (894) Start at 14:26:52 Duration 165.36s (transform 57%, environment 15%, import 13%, tests 8%, setup 8%) ``` ```text cargo clippy -p calternal-plugin --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 13m 04s cargo test -p calternal-plugin test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.76s cargo clippy -p calternal-plugin-money --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 50.21s cargo test -p calternal-plugin-money test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.93s cargo clippy -p calternal-server --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 13m 29s cargo test -p calternal-server test result: ok. 89 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 17.99s ``` `cargo clean` removed 15,247 files / 7.8 GiB. Removed `apps/web/build`, `apps/web/.svelte-kit/output` and `apps/web/target`. Worktree is clean. ## Decisions - Applied DESIGN §34's three-tab phone and five-tab wider-screen behavior at the shell breakpoint (390 phone, 820 tablet, 1440 desktop); every enabled mode stays in the scroll track and selection scrolls into view. - Kept absent Plugin slots in saved User order and placed Ask last in the canonical registry. - Applied the Money legacy migration by checking for a `Money/` folder; explicit saved User state wins. ## Known gaps The full local API adversarial round and a focused real-server reproduction expose a blocking crash in the existing Tags rename path. GDB shows SIGSEGV in `blake3::Hasher::new` while `calternal-fs::Root::replace_if` writes rewritten folder metadata from `calternal-tags::rename`. The focused run succeeds through rename preview, then `POST /api/v1/tags/rename` closes the connection and aborts the server with a Tokio worker stack overflow. I posted the call chain and evidence above. This behavior belongs to Tags and filesystem crates, outside the tray-owned change, so I did not alter either crate here; the adversarial merge gate remains unresolved and needs owner follow-up. The cold Files activation timing probe measured 113.2 ms and 342.5 ms at frame one against its existing 100 ms threshold. These are SLOW-only observations; the assertion remains unchanged.
Author
Owner

Gate-output formatting correction for the final report: the test summary lines emitted by Cargo had no leading space. Exact lines:

test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.76s
test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.93s
test result: ok. 89 passed; 0 failed; 2 ignored; 0 measured; finished in 17.99s
Gate-output formatting correction for the final report: the test summary lines emitted by Cargo had no leading space. Exact lines: ```text test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.76s test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.93s test result: ok. 89 passed; 0 failed; 2 ignored; 0 measured; finished in 17.99s ```
Author
Owner

Starting the tray-order width fix on job/tray-order.

Initial base: 3558457cf32e1d429da3834a05ff2720d11284302 (merge-base with the previously fetched origin/dev). The worktree was clean. I am merging the current origin/dev before editing, as requested.

Starting the tray-order width fix on `job/tray-order`. Initial base: `3558457cf32e1d429da3834a05ff2720d11284302` (merge-base with the previously fetched `origin/dev`). The worktree was clean. I am merging the current `origin/dev` before editing, as requested.
Author
Owner

Finding: the shared Tab Bar already receives a 3-item phone cap and a 5-item desktop cap, but the shell coverage did not exercise enabled counts 3, 4, 5 and 7 or compare the painted width with the item geometry. I added a shared effective-count clamp and a production-server E2E matrix that changes real User Plugin state, checks the last selected Tab remains visible, measures the capsule width, and saves screenshots at 390, 820 and 1440 px in light and dark.

Finding: the shared Tab Bar already receives a 3-item phone cap and a 5-item desktop cap, but the shell coverage did not exercise enabled counts 3, 4, 5 and 7 or compare the painted width with the item geometry. I added a shared effective-count clamp and a production-server E2E matrix that changes real User Plugin state, checks the last selected Tab remains visible, measures the capsule width, and saves screenshots at 390, 820 and 1440 px in light and dark.
Author
Owner

Finding: the production E2E burst selected Ask, then navigated to Calendar's /today route. The URL canonicalized to /calendar/today/2026-09-30 while the Tab Bar still selected Ask (Playwright diagnostic showed Ask selected, Calendar visible, scrollLeft 0). afterNavigate only cleared pending header selection for an exact path match. I updated it to clear after same-mode canonical redirects and added a 7-Tab return-to-Calendar regression assertion; production E2E is running now.

Finding: the production E2E burst selected Ask, then navigated to Calendar's /today route. The URL canonicalized to /calendar/today/2026-09-30 while the Tab Bar still selected Ask (Playwright diagnostic showed Ask selected, Calendar visible, scrollLeft 0). afterNavigate only cleared pending header selection for an exact path match. I updated it to clear after same-mode canonical redirects and added a 7-Tab return-to-Calendar regression assertion; production E2E is running now.
Author
Owner

Update: committed fix 0cf73d87. afterNavigate now clears the pending Tab header after a canonical redirect within the same mode. The production E2E matrix passes for 3, 4, 5 and 7 enabled Tabs at 390, 820 and 1440 px in light and dark; it also returns from Ask to Calendar and verifies the selected Tab stays visible at all three widths. Generated 24 screenshots under artifacts/tray-order/width-matrix; attaching them with the final report.

Update: committed fix 0cf73d87. afterNavigate now clears the pending Tab header after a canonical redirect within the same mode. The production E2E matrix passes for 3, 4, 5 and 7 enabled Tabs at 390, 820 and 1440 px in light and dark; it also returns from Ask to Calendar and verifies the selected Tab stays visible at all three widths. Generated 24 screenshots under artifacts/tray-order/width-matrix; attaching them with the final report.
Author
Owner

Results

  • Shared SegmentedControl now resolves the Tab Bar window as min(enabled Tabs, device cap). Three enabled Tabs use their natural width; longer lists keep every Tab reachable by horizontal scrolling. The selected Tab stays in view, and the #236 capsule spring remains in place.
  • afterNavigate now clears the pending Tab selection after a canonical redirect within the same mode. The 7-Tab E2E returns from Ask to Calendar and confirms Calendar is selected and visible.
  • Attached 24 production screenshots for 3, 4, 5 and 7 enabled Tabs at 390, 820 and 1440 px in light and dark themes.

Commits

  • Head: 93a60d72f069f4296aedf0f89abf8eef47bf3793
  • Feature and width profile: e429bc3f5, 2c242eb28
  • Canonical route selection fix and regression: 0cf73d87f

Files

  • packages/ui/src/components/segmentScroll.ts
  • packages/ui/src/components/SegmentedControl.svelte
  • packages/ui/src/components/TabBar.svelte
  • apps/web/src/lib/tray.svelte.test.ts
  • apps/web/src/routes/+layout.svelte
  • apps/web/e2e/shell.mjs, route-perf.mjs, harness.mjs
  • bench/record.py, bench/test_record.py

Gates

bun run check:

$ node scripts/check-type-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
Text sizes and UI shape values use shared role tokens.
Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/tray-order/apps/web
Getting Svelte diagnostics...

svelte-check found 0 errors and 0 warnings

bun run test:

 Test Files  137 passed (137)
      Tests  906 passed (906)
   Start at  18:14:36
   Duration  262.28s (transform 61%, environment 14%, import 12%, tests 10%, setup 2%)

The test run also printed existing jsdom Window.scrollTo() and CSS parse warnings. It had no failing tests.

Focused production E2E:

PASS Tab Bar width matches the visible item count at 390, 820 and 1440 px
CSP REPORTS shell: 0 across 1 pages

Benchmark report unit tests:

....
----------------------------------------------------------------------
Ran 4 tests in 1.658s

OK

bun run build completed with this adapter output:

> Using @sveltejs/adapter-static
  Wrote site to "build"
  ✔ done

cargo build -p calternal-server completed:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 10m 25s

No Rust source changed, so Rust fmt, clippy and test gates did not apply. Cleanup output:

     Removed 7068 files, 4.4GiB total

Performance

Measured at 0cf73d87f on perf-test under /root/perf.lock; the final commits after it are documentation-only. The run used 4× CPU throttle and one route-profile run. Lock-held load average was [0.02, 0.19, 0.73] before and [2.65, 0.93, 0.95] after.

  • Tab switching across counts 3/4/5/7: p50 409 ms, p95 603 ms (8 samples); server CPU 1.56%, RSS 149.8 MiB, peak RSS 155.3 MiB.
  • Worst case, 7 enabled Tabs and 20 selections: p50 7,543 ms, p95 13,213 ms (2 samples); server CPU 1.03%, peak CPU 7.99%, RSS 154.8 MiB, peak RSS 155.6 MiB.
  • Request burst: 80,833 requests at concurrency 24; p50 1.9 ms, p95 10 ms; all 80,833 responses were 200.
  • Existing baseline switch_mode is p50 1,203 ms, p95 2,151 ms, INP 560 ms at 4× throttle. This run's same interaction is p50 361 ms, p95 515 ms, INP 112 ms. The baseline used calternal-dev at load [25.38, 28.87, 30.08]; this run used perf-test. Treat these as context, not a direct comparison.

The machine result and report are in artifacts/tray-order/route-perf.json and artifacts/tray-order/perf.md.

Known gap

An earlier broad SHELL3_E2E_TRAY_ONLY=1 run timed out in the existing assertTrayViewport check with Notes selected, five Tabs visible and scrollLeft=0. I left that existing assertion unchanged. The requested focused 24-case matrix passes.

Decisions

  • visibleWindowCount is the single place that resolves the natural enabled count against a device cap. A finite fractional cap is floored and any non-positive cap resolves to one item; production callers use integer caps of 3 or 5.
  • The shell treats two routes as the same pending Tab selection when both resolve to the same mode. This covers canonical Calendar redirects and avoids clearing a pending selection for another mode.
  • The performance profile reads the live Plugin catalog before toggling optional Plugins because the shared release server can omit Plugins available in the local development build.

Screenshots

## Results - Shared `SegmentedControl` now resolves the Tab Bar window as `min(enabled Tabs, device cap)`. Three enabled Tabs use their natural width; longer lists keep every Tab reachable by horizontal scrolling. The selected Tab stays in view, and the #236 capsule spring remains in place. - `afterNavigate` now clears the pending Tab selection after a canonical redirect within the same mode. The 7-Tab E2E returns from Ask to Calendar and confirms Calendar is selected and visible. - Attached 24 production screenshots for 3, 4, 5 and 7 enabled Tabs at 390, 820 and 1440 px in light and dark themes. ## Commits - Head: `93a60d72f069f4296aedf0f89abf8eef47bf3793` - Feature and width profile: `e429bc3f5`, `2c242eb28` - Canonical route selection fix and regression: `0cf73d87f` ## Files - `packages/ui/src/components/segmentScroll.ts` - `packages/ui/src/components/SegmentedControl.svelte` - `packages/ui/src/components/TabBar.svelte` - `apps/web/src/lib/tray.svelte.test.ts` - `apps/web/src/routes/+layout.svelte` - `apps/web/e2e/shell.mjs`, `route-perf.mjs`, `harness.mjs` - `bench/record.py`, `bench/test_record.py` ## Gates `bun run check`: ```text $ node scripts/check-type-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json Text sizes and UI shape values use shared role tokens. Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/tray-order/apps/web Getting Svelte diagnostics... svelte-check found 0 errors and 0 warnings ``` `bun run test`: ```text Test Files 137 passed (137) Tests 906 passed (906) Start at 18:14:36 Duration 262.28s (transform 61%, environment 14%, import 12%, tests 10%, setup 2%) ``` The test run also printed existing jsdom `Window.scrollTo()` and CSS parse warnings. It had no failing tests. Focused production E2E: ```text PASS Tab Bar width matches the visible item count at 390, 820 and 1440 px CSP REPORTS shell: 0 across 1 pages ``` Benchmark report unit tests: ```text .... ---------------------------------------------------------------------- Ran 4 tests in 1.658s OK ``` `bun run build` completed with this adapter output: ```text > Using @sveltejs/adapter-static Wrote site to "build" ✔ done ``` `cargo build -p calternal-server` completed: ```text Finished `dev` profile [unoptimized + debuginfo] target(s) in 10m 25s ``` No Rust source changed, so Rust fmt, clippy and test gates did not apply. Cleanup output: ```text Removed 7068 files, 4.4GiB total ``` ## Performance Measured at `0cf73d87f` on `perf-test` under `/root/perf.lock`; the final commits after it are documentation-only. The run used 4× CPU throttle and one route-profile run. Lock-held load average was `[0.02, 0.19, 0.73]` before and `[2.65, 0.93, 0.95]` after. - Tab switching across counts 3/4/5/7: p50 409 ms, p95 603 ms (8 samples); server CPU 1.56%, RSS 149.8 MiB, peak RSS 155.3 MiB. - Worst case, 7 enabled Tabs and 20 selections: p50 7,543 ms, p95 13,213 ms (2 samples); server CPU 1.03%, peak CPU 7.99%, RSS 154.8 MiB, peak RSS 155.6 MiB. - Request burst: 80,833 requests at concurrency 24; p50 1.9 ms, p95 10 ms; all 80,833 responses were 200. - Existing baseline `switch_mode` is p50 1,203 ms, p95 2,151 ms, INP 560 ms at 4× throttle. This run's same interaction is p50 361 ms, p95 515 ms, INP 112 ms. The baseline used `calternal-dev` at load `[25.38, 28.87, 30.08]`; this run used `perf-test`. Treat these as context, not a direct comparison. The machine result and report are in `artifacts/tray-order/route-perf.json` and `artifacts/tray-order/perf.md`. ## Known gap An earlier broad `SHELL3_E2E_TRAY_ONLY=1` run timed out in the existing `assertTrayViewport` check with Notes selected, five Tabs visible and `scrollLeft=0`. I left that existing assertion unchanged. The requested focused 24-case matrix passes. ## Decisions - `visibleWindowCount` is the single place that resolves the natural enabled count against a device cap. A finite fractional cap is floored and any non-positive cap resolves to one item; production callers use integer caps of 3 or 5. - The shell treats two routes as the same pending Tab selection when both resolve to the same mode. This covers canonical Calendar redirects and avoids clearing a pending selection for another mode. - The performance profile reads the live Plugin catalog before toggling optional Plugins because the shared release server can omit Plugins available in the local development build. ## Screenshots - [3 Tabs · 390 px · paper](https://git.kayg.org/attachments/3361040d-6ad9-4d2d-a6b2-34655f88d92d) - [3 Tabs · 1440 px · paper](https://git.kayg.org/attachments/5eb0c969-228b-4166-bdaa-9a905f113469) - [3 Tabs · 1440 px · tokyo-night](https://git.kayg.org/attachments/ff050248-cf3d-4e60-af43-39957399a3ff) - [3 Tabs · 390 px · tokyo-night](https://git.kayg.org/attachments/5fd2cd6b-cbfd-4bed-96b6-4dcd1938178d) - [3 Tabs · 820 px · paper](https://git.kayg.org/attachments/86e374ff-8eb8-4eef-a567-f550a605fe33) - [3 Tabs · 820 px · tokyo-night](https://git.kayg.org/attachments/4985b44a-0e02-480a-92a1-fc7695b8c2cf) - [4 Tabs · 1440 px · paper](https://git.kayg.org/attachments/252229f1-947b-41a3-8e54-cefc7e90321d) - [4 Tabs · 1440 px · tokyo-night](https://git.kayg.org/attachments/387e3dd0-75d0-4259-8b4f-0ba9124861af) - [4 Tabs · 390 px · paper](https://git.kayg.org/attachments/efddf8aa-0329-4cf9-b06b-e746eed3abac) - [4 Tabs · 390 px · tokyo-night](https://git.kayg.org/attachments/b580cdf1-2e6e-4046-bdc9-2079748ce8bc) - [4 Tabs · 820 px · paper](https://git.kayg.org/attachments/4f14f9a7-fb79-4b98-9db7-0ef749019d60) - [4 Tabs · 820 px · tokyo-night](https://git.kayg.org/attachments/f2291566-400f-4902-9a2a-3f02a54df723) - [5 Tabs · 1440 px · paper](https://git.kayg.org/attachments/749a3694-b58c-4745-8a4b-4c800ca850ab) - [5 Tabs · 1440 px · tokyo-night](https://git.kayg.org/attachments/1c12ef36-13bf-4b23-b631-6135a3c4abe1) - [5 Tabs · 390 px · paper](https://git.kayg.org/attachments/27bdd2bf-fc47-4254-bfc7-00b5c87e0874) - [5 Tabs · 390 px · tokyo-night](https://git.kayg.org/attachments/92e501cb-ae0e-4c2b-9995-489610c8da3e) - [5 Tabs · 820 px · paper](https://git.kayg.org/attachments/2964e319-7440-4394-9038-6dd45655edba) - [5 Tabs · 820 px · tokyo-night](https://git.kayg.org/attachments/e5f219a8-4718-4c05-9afe-35e4c312f081) - [7 Tabs · 1440 px · paper](https://git.kayg.org/attachments/a872c6f4-e9a6-467c-98c4-2967c76c074f) - [7 Tabs · 1440 px · tokyo-night](https://git.kayg.org/attachments/3b615304-db0b-42d6-834c-0b3a53b370ab) - [7 Tabs · 390 px · paper](https://git.kayg.org/attachments/b296b89b-972e-48bb-a883-2380f7ed7c7e) - [7 Tabs · 390 px · tokyo-night](https://git.kayg.org/attachments/94b66437-1b39-4016-809b-027c92967cb5) - [7 Tabs · 820 px · paper](https://git.kayg.org/attachments/d4165bad-0cab-45b0-b901-a49db4bafcb8) - [7 Tabs · 820 px · tokyo-night](https://git.kayg.org/attachments/43f9c816-f31a-4fa9-9568-27b976b5cec9)
Author
Owner

Shipped in merge round 4, deployed to calternal.cloud in 1af8ead26 (healthy).

Shipped in merge round 4, deployed to calternal.cloud in 1af8ead26 (healthy).
Author
Owner

Correction: closed by mistake (the Tab Bar order work was mislabelled #34 in the orchestrator notes; it is #590/#591). The Immich importer is still open, see also #592 Immich parity.

Correction: closed by mistake (the Tab Bar order work was mislabelled #34 in the orchestrator notes; it is #590/#591). The Immich importer is still open, see also #592 Immich parity.
Sign in to join this conversation.
No labels
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#34
No description provided.