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
- 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>
Two related parity gaps from the VanillaJS → Svelte migration (issue #500):
internal-vs-external users weren't badged, and external users in a share's
member list rendered as a bare UUID with a static icon — no avatar, no email.
Both share one root cause: there was no shared user vignette and no resolver
for non-directory (external) users (the system address book lists internal
users only, and ShareDialog hardcoded isExternal=false).
- lib/api/endpoints/users.ts: resolveUser(id) — cached GET /api/users/{id}
(the authenticated per-user profile lookup) → {name, email, image,
isExternal}; returns null when the profile isn't visible so callers keep
their fallback label.
- lib/components/UserVignette.svelte: reusable identity chip — avatar (photo
or coloured initials), name, email, and a building-circle-xmark badge for
external users; resolves lazily and falls back to a caller-supplied label.
- lib/utils/avatar.ts: userInitials() + avatarColorIndex() extracted from
AppShell (now shared by both — no duplicated logic) so vignette and account
button render identically.
- ShareDialog: user member rows now render <UserVignette>; groups keep their
icon+label. Drops the dead hardcoded isExternal.
Backend already exposes everything (UserDto.email/image/is_external via
GET /api/users/{id}); no backend change. Frontend gate green (svelte-check
0/0, eslint, stylelint, prettier) + 47 Vitest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two parity gaps from the VanillaJS → Svelte migration (issue #500):
1. URL anchor when viewing a file. Opening a file now writes `?file=<id>`
to the URL, so a preview is bookmarkable, reload-restorable, and
Back/Forward open/close it. The viewer is driven from the URL via two
effects (URL→viewer with `untrack` so a user close can't be re-opened;
viewer→URL to drop the param on close with replaceState). Replaces the
write-less, load-only `maybeOpenDeepLink` (the `?file` reader that was
effectively dead because nothing ever set the param).
2. Multi-selection drag ghost. Dragging more than one item now sets a custom
drag image: a stack of the first few rows plus a count badge, reusing the
already-ported but orphaned `.drag-preview`/`.dragged-items`/
`.dragged-items-badge` styles. Previously a multi-item drag showed only the
browser's default single-row ghost with no count.
Also adds `static/geo/` to .prettierignore (the bundled minified world
basemap from #499 is a data asset and must stay byte-faithful — it was
failing `prettier --check`, blocking the gate).
Frontend gate green: svelte-check 0/0, eslint, stylelint, prettier, 47 Vitest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The normalized cursor-paginated /resources API is live and the bundled
frontend already uses it for favorites, recent, trash and grants. These five
deprecated old-format endpoints had no remaining frontend or protocol
consumers (the Nextcloud handlers call the service layer directly, not these
HTTP routes), so remove them for a uniform API and less duplicate listing
logic:
- GET /api/folders/{id}/contents → use /api/folders/{id}/resources
- GET /api/folders/{id}/contents/paginated → use /api/folders/{id}/resources
- GET /api/favorites → use /api/favorites/resources
- GET /api/recent → use /api/recent/resources
- GET /api/trash → use /api/trash/resources
Removes the HTTP handlers, their routes, OpenAPI path registrations, and the
now-dead list_folder_contents{,_paginated}_impl helpers + unused imports. The
underlying service methods (favorites_service.get_favorites,
trash_service.get_trash_items, etc.) are KEPT — the Nextcloud OCS/trashbin
handlers depend on them.
Deliberately NOT removed: GET /api/folders/{id}/listing. It is still the Files
view's primary data path and offers ETag/304 conditional caching plus
one-shot favorite/share badge sets that /resources does not yet provide;
migrating it needs a separate parity pass on /resources first.
Updates the OpenAPI structure test and the two docs that referenced the
removed paths. `cargo clippy -D warnings` clean; 443 lib tests pass; OpenAPI
regenerates with the 5 paths gone and the /resources replacements present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Places fell back to a flat themed background (blankStyle) when no Protomaps
.pmtiles basemap was present, so the map was just a grey void behind the
photo markers.
Replace that with worldStyle(): a MapLibre GL v8 style that draws land masses
and country borders over a themed ocean, sourced from a bundled Natural Earth
110m outline (public domain). The asset is geometry-only (properties stripped,
coords rounded to 2 decimals) — 177 features (148 Polygon + 29 MultiPolygon),
~165 KB — served same-origin at /geo/world-110m.geojson, so no external tiles
(CSP connect-src 'self' friendly) and no per-instance basemap install. A full
Protomaps basemap still takes precedence when one is installed.
Also add a quiet map 'error' handler: if the outline is ever missing, the SPA
fallback serves index.html (text/html) which MapLibre can't parse as GeoJSON;
markers still render over the ocean layer, so swallow the error rather than
logging to the console (mirrors the checkBasemap content-type guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Geotagged photos never appeared in Photos → Places because their GPS was
silently dropped. Capture-metadata extraction read GPS/camera/dimensions
only from kamadak-exif, which rejects many real-world EXIF blocks (phones,
photo editors, anything with a non-standard trailing IFD) with
`InvalidFormat("Unexpected next IFD")` and returns nothing. nom-exif —
already used to upgrade the capture date and far more lenient — parses those
same files fine, so only the date survived and latitude/longitude stayed
NULL.
nom-exif now also reads GPS (and the date) and `merge_image_metadata`
combines both sources:
- nom-exif's tz-correct date wins when present (unchanged behaviour);
- nom-exif's GPS only fills gaps kamadak left, so kamadak still wins when it
parsed the file (no regression);
- when kamadak fails outright, a record is still produced from nom-exif's
date/GPS instead of being discarded.
Existing photos can be backfilled via POST /api/admin/photos/metadata/reextract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`.nav-item` was ported from a <button> but the new AppShell renders it as
an <a>, and the rule carried every button reset except `text-decoration`.
With no global anchor reset, the nav rows (Files, Shared, Recent, …) showed
the browser's default link underline. Add `text-decoration: none` to the
base rule (covers normal, hover and active states).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues kept the Photos → Places map blank once the SPA could boot:
- CSP `worker-src 'self'` blocked MapLibre GL, which spawns its web worker
from a blob: URL, so the map never constructed. Allow `worker-src
'self' blob:` ('self' still covers same-origin workers like delta-upload).
- `checkBasemap()` trusted `res.ok`, but the SPA fallback serves index.html
(HTTP 200, text/html) for any missing path — so a missing basemap.pmtiles
read as "present", and pmtiles.js then choked on HTML ("Wrong magic
number for PMTiles archive"). Reject text/html responses so an absent
basemap falls back cleanly to the themed blank style.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The global `script-src 'self'` CSP header blocked every inline <script>,
so the SvelteKit hydration bootstrap never ran and the SPA never mounted
(blank page behind the splash spinner). The anti-FOUC theme init in
app.html was blocked too.
Instead of weakening the policy with 'unsafe-inline', the backend now
builds `script-src` at startup from 'self' plus a SHA-256 hash of every
inline <script> in the served HTML shells. The policy stays strict (no
'unsafe-inline' for scripts) and the hashes are recomputed from the built
assets on each startup, so a frontend rebuild needs no header edit — even
though SvelteKit's bootstrap hash changes every build.
- web::content_security_policy builds the header; web::resolve_static_path
is extracted so serving and hashing read the exact same bytes.
- byte-exact inline-script extraction (skips src= externals), unit-tested
against a known SHA-256 vector and extraction edge cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SvelteKit migration gated build.rs's static-dist/OUT_DIR generation
behind OXICLOUD_RUST_ASSETS=1 (early return), but login_v2_handler.rs
still embedded the page with
`include_str!(concat!(env!("OUT_DIR"), "/nextcloud-login.html"))`. With
OXICLOUD_RUST_ASSETS unset (the default), that file is never written to
OUT_DIR, so a clean `cargo build` failed to compile. (#489)
Migrate the page off include_str! to an askama template
(templates/nextcloud/login.html), mirroring the existing
DrivePickerTemplate in the same handler. This drops the only
compile-time dependency on the legacy build.rs pipeline, so the
OXICLOUD_RUST_ASSETS=1 CI workaround is no longer needed and is removed
from ci.yml, load-smoke.yml and load-nightly.yml.
Also fix the second failure on #489: with OXICLOUD_RUST_ASSETS=1 the
release pipeline panicked in copy_dir_recursive because `static/locales`
is now a symlink to frontend/static/locales. entry.file_type() reports
the link itself (not its target), so the symlinked directory was routed
to fs::copy and failed with "the source path is neither a regular file
nor a symlink to a regular file". Classify entries with fs::metadata,
which follows symlinks, so symlinked directories are traversed.
static/nextcloud-login.html is removed (its content moved into the
template; no other consumer) and dropped from build.rs HTML_INCLUDE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNCEMN6fC2xSmxqCVbstkd
The app is a pure client-rendered SPA (ssr=false, adapter-static), so until the
JS bundle downloads, parses and mounts, the page is blank — only then does the
layout's own loading state appear. True route prerendering would mean turning on
SSR, an architectural change at odds with the SPA design.
Instead, bake a tiny boot splash into the shell (`app.html`): a centered spinner
that paints the moment the HTML is parsed — before the app bundle or its CSS
load — covering the blank gap. The root layout removes `#app-splash` the instant
it mounts (before `session.load`), so public routes like /login (which render
without waiting for the session) appear immediately and protected routes hand
off to their own loading UI. `light-dark()` plus early `color-scheme` rules make
the splash match the resolved theme (incl. the saved override), so there's no
colour flash when the app CSS arrives; it respects `prefers-reduced-motion`.
Pure HTML/CSS in the shell — no new requests, no JS framework on the critical
path, ~0.6 KB in index.html. Verified on the real static-dist build in headless
Chromium: the shell ships `#app-splash`, the layout removes it after mount, the
app renders, and there are no runtime errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
`initI18n` runs in the client `init()` hook and blocks the first render. For
every non-English user it awaited TWO locale dictionaries back to back — the
active locale AND `en` (the fallback) — so first paint waited on two sequential
round-trips + JSON parses.
Now it awaits only the active locale, then warms `en` in the background
(non-blocking). `t()` only consults `dicts.en` for keys the active locale is
missing, and most call sites already pass an inline English fallback, so the
deferred `en` doesn't change what users see; when it arrives `dicts.en` is
reactive, so any key that fell through re-renders. English users are unchanged
(no second fetch was ever needed).
Net: non-English startup drops from two blocking locale fetches to one, halving
the i18n payload on the critical path (the server already serves these JSONs
brotli/gzip-compressed via the global CompressionLayer, so the wire cost was
already small — this removes the extra round-trip + parse from first paint).
Validated: new unit test (initI18n resolves while the en fetch is still pending,
en is kicked off in the background, and a key missing from the active locale
falls back once en lands) → 47 frontend tests green; npm run check; prod build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
Every navigation rebuilt the breadcrumb with one `GET /api/folders/{id}` per path
segment (a depth-D folder = D requests, each no-store) purely to label the trail.
Add an id→name cache, populated wherever a name is already known:
- every listing names its children, so `cacheFolder` records them, and
- `getFolder` records the folder it fetched.
`buildCrumbs` now reads names from the cache and only fetches the ids it hasn't
seen. During normal step-by-step navigation each ancestor was named by its
parent's listing, so the breadcrumb resolves with ZERO extra requests; only a
cold deep-link fetches its unknown ancestors (still in parallel). Folder renames
update the cache immediately so the trail stays correct.
The cache is a small LRU (cap 1000 — names are tiny) and is independent of the
listing cache (names survive a listing invalidation).
Validated: 3 new unit tests (listing populates child names, getFolder records,
rename overwrites) → 46 frontend tests green; npm run check; headless render of
the real files route (list + grid) — no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
Every folder navigation re-downloaded the full listing: `listFolder` cache-busted
with `?t=<ts>` + `Cache-Control: no-store`, so back/forward and re-entering a
folder always paid a full round-trip + payload.
Now the files browser caches listings in memory and serves SWR:
- On navigation it paints a previously-visited folder instantly from cache, then
revalidates with `If-None-Match` (the backend ETag covers folders + files +
favorite/share badges, so it's a faithful validator). Unchanged → 304 with an
empty body; changed → 200 refreshes cache + UI.
- A generation token guards against a slow in-flight response clobbering a newer
navigation; breadcrumbs now resolve independently so they never block the grid
paint.
- Mutations (create/upload/rename/move/copy/delete, incl. the move dialog) go
through `reload()`, which drops the cache and refetches fresh — no stale view
after an action.
API layer (`folders.ts`):
- `fetchFolderListing(id, { etag?, forceRefresh? })` does the conditional fetch
(200 → parsed listing + ETag, 304 → empty); `listFolder` stays as a
non-conditional wrapper for the move-dialog tree.
- A small LRU (cap 40) cache with `getCachedFolder` / `cacheFolder` /
`invalidateFolderCache`. `cache: 'no-store'` keeps the browser HTTP cache out
of the way; revalidation is driven entirely by our own ETag.
Net: instant back/forward navigation, and an unchanged folder revalidates with a
0-byte 304 instead of re-downloading the whole listing. Validated: 7 new unit
tests (conditional If-None-Match + 304, LRU eviction/recency, invalidation),
npm run check, and a headless render of the real files route (list + grid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.
Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
`PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
which already covers public links as 'token' grants — same membership the
/grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).
Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
badge sets straight from the listing and updates them optimistically on
favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
the single, authoritative, fetch-free source.
Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
The files browser re-fetched the first 200 favorites AND the first 200 outgoing
shares on every folder navigation (two round-trips each time) just to render the
star / shared badges — work that grew with how much the user browsed, for data
that barely changes.
Move both id sets into a session-scoped `badges` store: `ensureLoaded()` fetches
once (concurrent callers share one in-flight request) and every later navigation
reads from cache, so browsing costs zero extra requests. Mutations keep the cache
in sync optimistically:
- favorite toggle / batch-favorite → `setFavorite` (revert on failure),
- share creation → `markShared`, wired through a new optional `onshared` callback
on ShareDialog (fired when a grant or public link is created). This also makes
the shared badge appear immediately instead of only after re-navigating.
Net effect per session: badge fetches drop from O(navigations) × 2 to 2 total.
The 200-item ceiling is unchanged from before; the fully-correct fix is per-item
flags on the listing endpoint (a backend change, noted in the store).
Verified: new badges store unit tests (load-once, concurrent de-dupe, optimistic
favorite/share, reset) and a headless render of the real files route in list and
grid (virtualization intact, no runtime errors).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
Extends windowing to the remaining O(n)-DOM surfaces: the card-grid view of
ResourceList (recent / favorites / shared / shared-with-me / trash / search) and
the main file browser (`files/[...path]`), in both list and grid layouts.
- VirtualList gains a real grid mode: its inner window carries the caller's grid
class (`files-grid-view`) and lays out `columns` cards per windowed row. The
row pitch is auto-measured (grid card height tracks column width via the 4/3
aspect-ratio thumbnail) and re-measured on resize.
- `useVirtualWindow` now distinguishes scroll from resize and exposes a
`resizeTick`, so size-dependent layout (the grid pitch) is recomputed only when
it can actually change.
- `gridColumns(width)` (new util) mirrors the CSS `auto-fill` / `--grid-card-min`
/ gap so the windowed row count matches the browser's real wrapping exactly;
shared by both grid callers.
- The files browser flattens folders-then-files into one discriminated `entries`
list rendered through VirtualList (list: columns=1; grid: columns from width).
Grouped (swimlane) views stay fully rendered, as before — they're bounded.
ResourceList GRID, headless Chromium (1280x900), synthetic rows, before/after:
rows | mount→paint | DOM nodes | JS heap | scroll frame | jank frames
------+-------------+-----------+---------+--------------+------------
1000 | 979→75 ms | 19k→879 | 16→3 MB | 16→17 ms | 0→0
5000 | 4005→67 ms | 95k→879 | 72→3 MB | 37→17 ms | 26→0
20000 |12015→65 ms | 380k→879 |281→7 MB |197→17 ms |1249→19
Rendered DOM and heap are flat (O(visible)) regardless of dataset size; mount is
~185x faster and scroll holds ~60fps. Verified visually mid-scroll (5 columns,
4/3 thumbnail tiles, cards land at the expected indices). The files browser
shares the same VirtualList path (it can't be mounted headless — it depends on
$app routing/session — so it's validated via svelte-check + the production build
+ the shared, separately-benchmarked component).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
The Photos "moments" grid rendered every tile into the DOM, so a 20k-photo
library mounted ~140k nodes / 20k <img> elements, held ~196MB JS heap, took
~3.5s to first paint and scrolled at ~6fps. It also ran the justified-layout
maths inside the template (recomputed on every reactive change) and generated
client-side video thumbnails for every off-screen video, not just visible ones.
Introduce `VirtualRows` — a variable-height, section-aware sibling of
`VirtualList` — and flatten the grouped timeline into one list of fixed-height
rows (a date header or a strip of explicitly-sized tiles) shared by both the
square and justified layouts. Only the rows near the viewport are mounted; a
prefix-sum offset table + binary search find the visible band, and a spacer
reserves the full height so the sticky header and load-more sentinel are
unchanged. The justified packing now runs once per groups/width/layout change
in a $derived, not per render.
To avoid duplicating the scroll-tracking logic across the two windowing
components, extract it into a `useVirtualWindow` composable (scroll-ancestor
detection + rAF-throttled aboveBy/viewportH signals); `VirtualList` is
refactored onto it with identical measured numbers.
Measured in headless Chromium (1280x900), synthetic photos, before/after:
SQUARE | mount→tiles | DOM nodes | <img> | JS heap | scroll frame
------------+-------------+-----------+-------+---------+-------------
2000 | 416→94 ms | 14k→629 |2000→96| 21→5 MB | 29→29 ms
5000 | 916→114 ms | 35k→629 |5000→96| 50→9 MB | 62→26 ms
20000 | 3455→220 ms | 140k→629 |20k→96 |196→29 MB|152→33 ms
JUSTIFIED 20000: mount 245 ms · DOM 315 · <img> 44 · heap 33 MB · ~60fps
Rendered DOM, mounted <img> count and heap are now flat (O(visible)) regardless
of library size; mount is ~16x faster and scroll jank drops from 413 to ≤24
frames. Off-screen video-thumbnail generation no longer fires for non-visible
tiles. Correctness verified by probing a deep scroll in both layouts (tiles
land within the viewport band; square cells equal-width, justified rows
aspect-preserving).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
remove create_home_folder() & ensure_home_folder()
now: on_user_created() and on_user_login both() call provision_if_needed()
which calls **create_personal_drive_atomic()**
add a helper to find Personal drive for a user and also it's root directorry
ensure that a user that don't havee permission to a drive cannot search elements in this drive
note: current design: Index are associated to drive, so if a document / directory is shared,
the shared resource will not be in index for targetted user
design is explicitely as is to reduce complexity