ShareDialog (~15 KB JS) and MoveDialog (~5 KB JS) were statically imported by the
files, favorites, recent and shared routes, so they downloaded on every visit
even if the user never opened a share/move dialog. Convert them to the existing
lazyComponent pattern (as already used for FileViewer/WopiEditor): the chunk is
fetched the first time the dialog is opened.
The Vite manifest confirms both flip from static to isDynamicEntry. This defers
~26 KB raw / ~9.5 KB gzipped (JS + CSS) off the initial load of those four routes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Checkpoint of the in-progress frontend toolchain work (Vite pinned to ^6 after
the 7/8 rolldown build break, eslint-plugin-svelte v3 navigation/reactivity
fixes, CI/Dockerfile/manifest updates) together with three UI performance
optimizations (verified on the Vite 6 build):
- Critical CSS: move auth.css/music.css off the global path into their route
chunks (login/device/nextcloud-login, music) -- -25% gzipped critical CSS
(~5.4 KB) on every non-auth/non-music page load.
- relativeTimeAgo: cache the Intl.RelativeTimeFormat (was rebuilt per call, once
per row per render) -- 22.7x faster date formatting in large lists.
- Virtualize search results and grouped trash (list view) via VirtualList -- DOM
rows mounted stay ~constant (~27) instead of O(N) (94.6% fewer for 500 hits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`frontend/static/manifest.webmanifest` was not prettier-formatted (2-space
indent vs the repo's tab style), so `npm run check`'s `prettier --check .`
step — and therefore the frontend CI job — fails on it for every PR. Run
`prettier --write` to bring it in line. Whitespace only; no semantic change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OIDC SSO login intermittently ended on a 403 "Invalid or expired OIDC state
— possible CSRF attack" even though the login had already succeeded
server-side.
Root cause: the (now-removed) legacy vanilla-JS frontend registered a
`/sw.js` service worker that, with navigation preload enabled, double-fetched
the top-level navigation to `/api/auth/oidc/callback`. The OIDC `state` is
single-use, so the first callback consumed it and logged the user in while
the duplicate (~0.4s later) found the state gone and returned the 403 the
browser rendered.
Backend — idempotent callback: after a successful web login, remember
`state -> exchange_code` in a short-lived (120s) cache. A duplicate callback
whose state was already consumed now replays that same redirect instead of
403-ing, returning the cached result directly without re-running the IdP code
exchange (the authorization `code` is single-use too). Keyed by the
unguessable 32-byte state, so it adds no new attack surface and fixes the 403
for everyone — including browsers still running a stale legacy service worker.
Frontend — evict the stale worker: the current SvelteKit app registers no
service worker, so fresh clients can't double-fire. But a browser that
previously loaded the legacy frontend still has `/sw.js` registered and
controlling pages (and `/sw.js` now 404s, so vendor self-cleanup is
inconsistent). killLegacyServiceWorker() runs first in the root layout's
onMount: it surgically unregisters only `/sw.js` workers, drops only the
legacy `oxicloud-cache-*` caches, and reloads once (guarded).
Fixes#510.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The owner short-circuit in PgAclEngine::check ran a PK query
(SELECT user_id FROM storage.folders/files WHERE id=$1) on every authorization
check of a folder/file — the common case, since users mostly act on their own
resources. Memoise it in an owner_cache (moka, TTL 300s, 100k cap). The owner
column is immutable, so this is safe: the cache maps resource -> real owner and
can never grant a non-owner access (a different caller's owner==uid test fails
against the cached owner and falls through to grants); a hard-deleted resource
that briefly resolves to its former owner simply fails later at execution with
NotFound. The per-check sql_queries counter now increments only on a miss.
Removes 1 DB query + 1 pool-connection acquisition per owner check. Magnitude is
deployment-specific (query latency x whether the pool is contended); see
benches/ACL-OWNER-CACHE.md.
Also adds two DB perf-investigation harnesses, gated behind the `bench` feature
(need the dev Postgres; zero prod impact):
- examples/bench_db_pool.rs + benches/DB-POOL.md — pool size vs tail latency
- examples/bench_owner_cache.rs + benches/ACL-OWNER-CACHE.md — owner query vs cache
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benchmark refuted the rayon-oversubscription hypothesis: throughput stayed flat
at the real operating point (14 permits) and PNG single-image latency regressed
66% when the 3-size resize was made sequential. The code was reverted; this
records the negative result in benches/BASELINE.md so it isn't retried (like the
dropped defer-Large task).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the image crate's scalar resampler with fast_image_resize
(AVX2/SSE4.1/NEON) in a shared encode_thumbnail() helper. render_all now
converts to RGB8 once and SIMD-resizes the shared buffer per size. Lanczos3 for
downscaling, CatmullRom when upscaling (Lanczos rings on enlargement).
Also folds the duplicated path-variant generate_all_sizes_background into the
shared render path -- it had missed BOTH shrink-on-load and SIMD resizing -- so
every thumbnail path now goes through one optimised routine (no duplication).
Measured on 14 cores vs the post-1.5 state (benches/BASELINE.md):
- PNG 2.60x faster (33.6->12.9ms), GIF/WebP 1.25-1.6x: full-resolution decode
paths where the resize dominates, so SIMD helps most
- JPEG only ~7% (shrink-on-load already shrank the bitmap) but peak heap fell
another ~2.5x (17.6->7.1MB): tight RGB buffers, RGB conversion once
- quality SSIM 0.986-0.994 at identical dims (>=0.98 gate)
Thumbnails are now exactly max_dim on the long side (e.g. 400x266) vs the old
fit-within 399x266 -- a <=1px change, invisible under object-fit: cover.
Bench example gains an exact-dims quality reference + semaphore throughput table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shrink-on-load decoupled peak heap from source resolution (~18-25 MB per decode
regardless of MP), so the RAM ceiling that justified halving decode concurrency
is gone. max_concurrent_decodes() now defaults to all cores, with an
OXICLOUD_THUMBNAIL_DECODE_CONCURRENCY override for ops tuning.
Measured on the real service path (Semaphore + spawn_blocking), 14 cores:
- 12MP: 92.7 -> 133.7 photos/s (1.44x)
- 24MP: 49.5 -> 69.9 photos/s (1.41x)
peak heap unchanged; cpus*2 yields nothing, confirming cpus is the right ceiling
for CPU-bound work (the gap to 2x is rayon oversubscription -- Task 1.7).
Adds a semaphore-bounded throughput harness (Table D) to the bench example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decode JPEGs at the smallest DCT scale (1/8·1/4·1/2·1/1) whose long axis is
still ≥ the largest needed thumbnail (800px), via jpeg-decoder, instead of a
full-resolution decode through the image crate. The full-res bitmap — the
dominant time and RAM cost — is never materialised. PNG/GIF/WebP and unusual
JPEG colour spaces (CMYK / 16-bit grey) fall back to a full decode.
Extracts the shared decode + EXIF-orientation logic into decode_oriented(),
removing the duplication that existed between render_thumbnail_from_data and
render_all_thumbnails_from_data.
Measured on 14 cores (see benches/BASELINE.md):
- render_all 1.8-2.0× faster (12MP 111->61ms, 48MP 398->203ms)
- peak heap 5.5-14.8× lower, now decoupled from source MP (~18-25MB regardless)
- saturated throughput 3-3.6× (parallel efficiency 4.9×->8.5×)
- quality SSIM 0.987-0.999 (>=0.98 gate), PSNR 47-55dB
Also adds the Phase 0 benchmark harness (gated behind the `bench` feature, zero
prod impact): deterministic image corpus (src/bench_support.rs), criterion
latency bench (benches/thumbnails.rs), and a peak-RAM/throughput/SSIM harness
(examples/bench_thumbnails_mem.rs). Baseline + before/after in benches/BASELINE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two robustness fixes behind the recurring "folder upload stuck at ~93%" reports.
1. Skip non-regular files up front. A copied s6/runit service tree contains
FIFOs (e.g. supervise/control named pipes) that report a size but BLOCK
FOREVER when the browser reads them — the deterministic ~8-files-short that
no retry/watchdog tweak could fix. uploadBatch/uploadTree now probe each
file's first chunk against a 3 s timeout (partitionReadable), upload only the
readable ones, and report the rest: "N uploaded · M skipped (not regular
files)". Progress runs over the uploadable count, so it reaches 100% instead
of parking at 93% while a lane hangs on a pipe.
2. Auto-reload on a new deploy. svelte.config.js polls _app/version.json
(60 s); the root layout reloads itself when the deployed build changes —
unless an upload is in flight — so an open tab can't keep running stale code
after a rebuild (the recurring "my fix isn't applied" trap).
npm run check: 0 errors, 58 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An audit (backend /api routes vs SvelteKit frontend usage, adversarially
verified across the whole repo) found these 5 routes have ZERO callers — no
frontend, no test, no protocol layer, no internal caller — and are superseded:
- GET /api/folders/paginated no-op duplicate of GET /api/folders
(discards the page arg); superseded by
the cursor-paginated /{id}/resources.
- POST /api/dedup/upload superseded by /api/files/upload, which
does the identical CDC dedup ingest.
- POST /api/people/{id}/hide the hide-person toggle was never built
into the UI (is_hidden never read).
- GET /api/admin/settings/general never called anywhere.
- GET /api/admin/settings/registration only the PUT is used; the GET had no
caller (PUT kept).
Removes each route, its handler + _impl, the now-orphaned DedupUploadResponse
DTO + its two serialization tests, the set_hidden service method (only caller
was hide_person), and the OpenAPI path/schema registrations.
Also cleans 4 stale markers: two #[allow(deprecated)] that no longer suppress
anything (zero #[deprecated] remain), the "Legacy folder endpoints (contents,
listing)" comment (both already removed), and a "Re-export AppError for backward
compatibility" comment describing a re-export that doesn't exist.
Net -323 lines. The 31 other unused-by-frontend routes (device-code auth,
CardDAV contact-groups, people/photos & music WIP, dedup/admin debug, i18n,
openapi.json) are intentional surface and were left untouched.
cargo clippy --all-features --all-targets -D warnings: clean. cargo test: 446 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The legacy-frontend removal dropped the deprecated /api/folders/{id}/listing
route, but folders.ts still called it, so every folder view 404'd
("listing failed: 404"). Complete the migration: fetchFolderListing now pages
through the cursor-paginated /api/folders/{id}/resources feed and rebuilds the
combined {folders, files} listing the views expect.
- Pages through next_cursor (limit 200) and splits mixed resource items by
resource_type. 403 still throws; the 304/ETag fast-path is gone (that feed has
no whole-listing ETag) so the in-memory folderCache is the only revalidation.
- Favorite/share badge sets aren't carried by /resources, so they come back
empty for now (no star / share badge until wired from /favorites + /shares).
- folders.test.ts updated to the paginated shape.
npm run check: 0 errors. 58 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SvelteKit app under /frontend has fully superseded the legacy
vanilla-JS/CSS frontend in /static, which was only ever served by a
debug `cargo run` / `PROFILE=dev` and never shipped to production.
Remove it together with the whole subsystem that existed only to
support it (~54k lines).
Frontend & assets:
- Delete /static (js/, css/, *.html, sw.js, basemaps/, locales symlink).
- Relocate the brand/PWA assets (logo/, favicon.ico, manifest.webmanifest)
to frontend/static/ so they ship with the SPA. This also fixes the
favicon, which app.html referenced but was missing from the prod bundle.
- Migrate the Nextcloud login-flow redirects from /nextcloud-error.html
to the SvelteKit /nextcloud/error route.
Web layer:
- Simplify resolve_static_path: drop the PROFILE=dev branch; always prefer
the Vite static-dist/ build, fall back to the configured path.
- Resolve i18n locales from the served SPA dir with a frontend/static
fallback so `just dev` works without a prior build.
Build:
- Prune build.rs from 1262 to ~70 lines (git metadata only); the Rust asset
pipeline and the OXICLOUD_RUST_ASSETS rollback flag are gone.
- Drop the now-unused build-dependencies (oxc_*, lightningcss).
- Remove the COPY static lines from the Dockerfile (cacher + builder).
Tooling & docs:
- Delete biome.json, jsconfig.json, tools/check-*.py, identifier.sh.
- Remove the legacy front-* justfile recipes; repoint the design-system
scripts (locales, dead-tokens, brand-drift, token-docs) at the frontend,
and drop check-contrast/check-headings (coupled to the old token
taxonomy / multi-page HTML).
- Repoint docs/DESIGN-SYSTEM.md links; remove 5 superseded docs/plan/*.
Backend dead code:
- Remove the dead `folder_repo` field from FileBlobWriteRepository.
- Remove the deprecated GET /api/folders/{id}/listing endpoint
(superseded by /resources).
Verified: cargo clippy (all-features/all-targets) clean, cargo test
--workspace 448 passed, cargo fmt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the connection-exhaustion fix. Rather than routing large files to
plain uploads (which kept STORAGE dedup but gave up delta's re-upload bandwidth
savings), keep delta for every file >= 8 MB and instead cap each worker's
concurrent connections so a few large files uploading at once can't blow past
the browser's ~6-per-host budget and starve the small-file plain uploads.
- deltaWorker.js: serialize negotiate (at most one in flight per worker) and
drop chunk-PUT concurrency 2 -> 1, so each worker holds ~2 connections max.
- deltaUpload.ts: revert the 64 MB threshold back to 8 MB — every large file
gets sub-file dedup again. (Storage dedup was never affected: BLAKE3 + CDC +
ref-counting run server-side for plain and delta uploads alike.)
With main upload concurrency at 2, total in-flight upload connections stay <= ~4.
npm run check: clean, 58 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With WASM finally enabled, large files (e.g. 32 MB logs) started running the
delta worker, which opens SEVERAL concurrent requests each (overlapping
negotiate batches + chunk PUTs). A few of those running at once blew past the
browser's ~6 connections-per-host limit, so plain uploads of the small files
queued with zero bytes sent until the 30 s stall watchdog cancelled them — the
upload "stuck at 4% / 94%" with N (pending) XHRs in the Network panel. The
session-refresh request got starved too (the spurious 401s).
- Raise the delta-worker threshold to 64 MB (new DELTA_WORKER_MIN_SIZE) so
typical large files take a single-connection plain upload. Delta's payoff is
sub-file dedup on RE-upload; on a first upload it is pure connection overhead.
Client-side instant-hashing still only reads files < 8 MB into memory.
- Lower upload concurrency 3 -> 2, leaving headroom under the 6-connection
budget for session refresh/poll and the occasional delta worker.
npm run check: clean, 58 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auth screens rendered the OxiCloud cloud mark with viewBox "120 120 280 280",
whose left edge (x=120) cropped the cloud's left side (the path starts at x≈107).
Align them to the AppShell (logged-in) logo's viewBox "95 67 320 320" — same cloud
path — so the mark is fully visible and centred with proper padding inside the
badge, matching the in-app logo everywhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of folder uploads "freezing at ~95%": the global Content-Security-
Policy `script-src` was `'self'` + inline-script hashes with NO
`'wasm-unsafe-eval'`. Chromium therefore blocked `WebAssembly.instantiate`
("Wasm code generation disallowed by embedder"), so the vendored BLAKE3/FastCDC
WASM threw on instantiation — both on the main thread (instant by-hash uploads
and the batch dedup check) and inside the delta-upload worker. Every file then
fell back to a plain byte upload, and the backend logs showed 0 check-batch /
0 negotiate calls. Large files (32 MB service logs) compounded it and the
session token expired mid-upload, so the last handful failed.
- web/mod.rs: add `'wasm-unsafe-eval'` to `script-src`. WASM-only, safe variant
— does NOT enable `eval()`/`new Function()`. Restores instant uploads, delta
(sub-file dedup), and the client hashing the idempotent re-upload relies on.
- deltaUpload.ts: liveness watchdog on the delta worker. A healthy worker posts
progress sub-second; if it goes silent for 20 s it is wedged (WASM init or
chunking hung without throwing) — disable delta for this file AND every later
one so they fall straight through to a plain upload instead of each burning
the full 120 s+ delta timeout. Defense-in-depth so a broken WASM path can
never again freeze an upload for minutes.
cargo test: pass. npm run check: clean, 58 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-uploading a partially-uploaded folder used to surface hundreds of spurious
"already exists" failures, and a file the watchdog aborted (or one the server
committed just before the client gave up) was lost.
Backend — save_file_with_blob_impl (the shared write path for both plain and
by-hash uploads): on a name conflict (23505), if the existing non-trashed file
holds byte-identical content (same folder, same name, same blob hash), return
that file as success instead of erroring. A different-content clash still
conflicts. Re-upload / re-sync becomes a clean no-op for everything already
stored — only the genuinely missing files transfer.
Frontend — uploadWithRetry: each file gets one automatic retry on a transient
failure (quota is never retried). With backend idempotency, retrying an
already-stored file is an instant no-op and a stalled/aborted file gets a real
second chance, so a folder upload self-completes instead of leaving gaps.
cargo test: 448 passed. npm run check: clean, 58 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A folder upload with several large files could appear frozen for ~2 min: a few
concurrent uploads stalled and the old 120s per-file timeout neither aborted the
request (leaving zombie XHRs that exhaust the browser's per-host connection pool)
nor recovered quickly.
- uploadFileWithProgress now self-aborts on a stalled connection: the deadline
resets on every upload-progress tick (a slow but *moving* transfer is fine),
and once the body is sent the server gets a fixed window to respond; on a stall
xhr.abort() frees the connection immediately — no zombie, no cascade.
- Lower upload concurrency 4 -> 3 to reduce server contention from large
concurrent uploads.
- The outer per-file timeout is now just a generous backstop for a wedged delta
worker / by-hash request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend:
- POST /api/dedup/check-batch — returns the subset of submitted whole-file
BLAKE3 hashes the caller already owns, in one query (user-scoped,
anti-enumeration via idx_files_blob_hash). Lets a client learn which of N
files it can skip with a single round trip.
(dedup_service::user_owned_blob_references, dedup_handler, routes) + tests.
Frontend — upload pipeline:
- Instant ("by-hash") upload for content the caller already owns: hash every
in-band file, ONE /api/dedup/check-batch, create the owned ones with zero
content bytes, upload only the rest. Covers all sizes below the 8 MB delta
threshold (delta handles larger files). vendor/hashWasm computes the
whole-file BLAKE3 on the main thread.
- Resilient parallel uploads: bounded concurrency (4) + a per-file deadline,
so one stuck/slow/failing file no longer freezes the whole batch — it blocks
only its own lane and times out / is skipped while the rest proceed. Quota
exhaustion stops the run early; partial results are reported ("N uploaded,
M failed").
- Folder uploads (uploadTree) show live bell progress + a final result and go
through the same dedup + parallel pipeline.
- Storage bar ("Almacenamiento") refreshes after uploads/deletes
(session.refresh) instead of showing the stale login value.
Frontend — i18n / UI fixes:
- Fix literal {{count}} and {{percentage}}/{{used}}/{{total}} (param-name
mismatches) in the selection toolbar and storage line; add es strings.
- Remove the underline on user-menu link rows.
Benchmark (uploadStrategies.bench.test.ts) compares baseline / per-file / batch:
the batch collapses N per-file probes into one check (e.g. a WAN 1000-file run
drops from 1700 to 1001 round trips) while matching per-file's byte savings.
Also includes in-progress group virtual-description i18n work present in the
working tree (groups.ts, ResourceList, locale `groups` keys).
Verified: cargo clippy -D warnings (clean), backend 448 tests; frontend
npm run check (clean), 58 unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundles the backend+frontend delta-upload (content-dedup) feature with a
batch of frontend fixes from this session.
Upload / dedup:
- Client-hashed delta & instant upload (deltaUpload, hashWasm vendor shim)
- Backend dedup batch endpoint (dedup_service, dedup_handler, routes)
- session store owned-hash helpers; unit tests + upload-strategy bench
Frontend UI/UX:
- Colour file-type icons in grid/list (per-type tinted tiles + glyph hue)
- Robust thumbnail fallback; PDFs now show their type icon (backend
generates no PDF thumbnails) instead of a blank tile
- Fix PDF preview: load via a same-origin blob: iframe — the API URL is
blocked by the global X-Frame-Options: DENY in the browser's framed
PDF viewer, matching the existing CSP `frame-src blob:` design
- Groups: localized virtual-group description (no DB schema-note leak),
add nav.groups to the 15 missing locales, fix primary-button contrast
- Repoint --color-text-light → --color-on-accent (was faint grey on accent)
- Nudge the admin role badge off the user-menu header divider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend — tail latency & throughput:
- FileContentCache, image transcode, and search now use moka single-flight
(try_get_with / get_or_load) so N concurrent misses for the same key
collapse to one disk read / transcode / query instead of a thundering herd.
Microbenchmark (128 concurrent on one hot key): 128 loads / p99 ~1023ms
before vs 1 load / p99 ~32ms after.
- DB: configurable per-statement timeout on the primary pool
(OXICLOUD_DB_STATEMENT_TIMEOUT_SECS, default 30; maintenance pool exempt) so
a runaway query can't pin a connection and starve the pool.
- DB: background pool-saturation monitor
(OXICLOUD_DB_POOL_MONITOR_INTERVAL_SECS) that WARNs as the primary pool nears
exhaustion — the early signal before tail latency cliffs.
- mimalloc: set MIMALLOC_PURGE_DELAY=0 (Dockerfile + compose) so freed pages
return to the OS and RSS tracks the live working set; benchmarked on
musl/aarch64 at ~400MB reclaimed vs 0MB with the default.
Frontend — UI / i18n fixes:
- i18n: fix literal "{{count}}" and "{{percentage}}/{{used}}/{{total}}" in the
selection toolbar and storage line — the call sites passed param names that
didn't match the locale placeholders; unify on `count` and pass the storage
template its params. Add es files.selected_count.
- sidebar: hide the drive picker when there's only one drive (the redundant
"Personal" row); remove the coloured left accent on the active nav item.
- logo: stop clipping the cloud's left bulge — viewBox recentred on the cloud's
true bbox with proportional SVG size so it keeps the same rendered scale.
- user menu: drop the default <a> underline on the link rows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- photos: generate + upload the three video-thumbnail sizes in parallel via
Promise.allSettled instead of a sequential await loop; previewData is still
captured before its upload so the local preview survives a failed upload.
- useOwnerCache: resolve the batch, then apply a single reactive assignment
to `#names` instead of spread-copying the record once per id (fewer copies
and fewer derive re-runs on large resolves).
- admin: migration status polling slowed from 2s to 5s.
- files store: soft cap (10k) on the per-item selection toggle. Bulk
"select all" in the views is intentionally left uncapped.
The three tiers had collapsed to one identical value (#5e6a78 / #9fadbe),
so hints, metadata, timestamps and placeholders were all indistinguishable.
Re-separate them into three perceptibly distinct steps, each verified WCAG
AA >=4.5:1 on every page/surface/hover/muted background in both light and
dark mode. Emphasis maps to contrast: muted is strongest (general secondary
text), faint the weakest (timestamps/placeholders) sitting on the AA floor;
secondary stays clearly stronger than all three. The dark floor is binding,
so the dark steps separate upward (lighter = more emphasis).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013J5koaCrHDMvS7uwpawLBN
Friendly/warm direction, modernised:
- Buttons: drop the translateY lift on every variant (it read jittery and
dated). They now rest on a soft neutral elevation, bloom their warm brand
glow in on hover, and scale-down 0.98 for a tactile press. Motion uses the
--motion-base / --ease-standard tokens.
- Scrollbar: neutral padded pill at rest that warms to the accent on hover,
instead of an always-orange thumb that competed with content.
- page-sticky-header: replace the magic top: -20px (coupled to the content
gutter) with calc(-1 * var(--space-5)); tokenise its z-index to --z-sticky.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013J5koaCrHDMvS7uwpawLBN
Defer loading components that are off the initial render path until they
are first needed, keeping them out of the bundle that loads on page entry.
- Add `lazyComponent` composable: a tiny rune-based holder that dynamic-
imports a component on first `load()` and exposes it for `{@const}`
rendering, with the component type inferred from the module so prop/
binding type-checking stays intact.
- CommandPalette: loaded on the first Cmd/Ctrl+K and mounted open via a
new `autoOpen` prop. It was previously imported by AppShell, i.e. in the
initial chunk of every authenticated route (~27 KB). AppShell now owns
the shortcut that triggers the load.
- FileViewer + WopiEditor: loaded when a preview/editor first opens, across
files, recent, favorites and shared-with-me.
- PhotoLightbox / PlacesMap / PeopleView: loaded on first lightbox open or
when the places/people tab is selected (the latter also defers maplibre).
Once loaded a component stays mounted and behaves exactly as before, so
behavior is unchanged on the second use onward.
- About modal overlay used a raw z-index: 1200, below the semantic
--z-modal (3000)/--z-toast (4000) layers, so token-based surfaces would
cover it. Consume var(--z-modal) instead.
- .btn-primary and .search-button sit on the accent gradient but coloured
their text with --color-danger-text (only correct by coincidence, #fff).
Use the purpose-built --color-on-accent so retinting danger can't break
them. The other --color-danger-text uses are white-on-red and stay.
- --color-item-hover-blue/--color-item-hover-sky were light-only literals
that would read wrong in dark mode; wrap them in light-dark().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013J5koaCrHDMvS7uwpawLBN
The per-section icon colours used :nth-child(1..6), but the nav grew to 8
items (files, shared, shared-with-me, recent, favorites, photos, music,
trash). The colours had drifted out of sync with their labels, and music
and trash got no colour at all.
Map each item's colour off a stable `data-section` key instead, covering
all eight sections with distinct curated calendar-dot hues (sibling blues
for the two share directions, purple for music, red for trash). Reordering
or adding nav items can no longer desync the colours.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013J5koaCrHDMvS7uwpawLBN
Last parity gap from the VanillaJS → Svelte migration (issue #500),
frontend-only — the backend endpoints already exist.
- Drag-out download: onItemDragStart now also sets the DataTransfer
`DownloadURL` type, so dragging a row/selection onto the OS desktop
downloads it — a single file directly (GET /api/files/{id}), a folder as a
zip (GET /api/folders/{id}/download?format=zip), and a multi-selection as
one server-zipped archive via the GET twin GET /api/batch/download
(DownloadURL can only point at a GET URL). The zip name is shared with the
in-app batch download via a new batchZipName() helper.
- Folder drag-drop upload: onDrop now walks dropped directory trees with
webkitGetAsEntry/createReader into {file, relativePath} rows and recreates
the tree server-side, instead of dropping only a folder's top-level files.
The recursive-upload core is extracted into uploadTree() and shared with the
folder picker (onUploadFolder), so both paths behave identically. Plain
multi-file drops keep the existing flat fast-path.
Stacked on #502 (shares onItemDragStart). Frontend gate green (svelte-check
0/0, eslint, stylelint, prettier) + 47 Vitest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>