Skip to content

feat: upgrade architect#10

Open
bitxwave wants to merge 77 commits into
masterfrom
feat/rust-platform
Open

feat: upgrade architect#10
bitxwave wants to merge 77 commits into
masterfrom
feat/rust-platform

Conversation

@bitxwave
Copy link
Copy Markdown
Owner

Summary

Re-architects the project from a static SvelteKit page with hard-coded TypeScript constants into a self-hostable navigation platform: Rust
backend (Axum + SQLite + tower-sessions) + Svelte 5 SPA + single Docker image
. After this PR, the admin can log in, add/remove nav items
inline, change the password, and the data persists in SQLite — no rebuild required.

Built across 5 design plans (committed alongside the implementation under docs/superpowers/):

# Plan Commits
1 Rust backend (Axum + SQLite + auth + CRUD + CLI) 33
1.5 Svelte 3 → Svelte 5 + SvelteKit 2 + Vite 5 + ESLint 9 + Prettier 3 + pnpm 10 11
2 Frontend foundation (zod types + apiClient + i18n + design tokens + 10 UI primitives) 23
3 Read-only browse (stores + Header/Footer/NavGrid + search/tags/themes/region) 24
4 Edit mode MVP (login + ItemEditDialog + context menu + change password) 14
5 Docker + Playwright e2e + README 13
Total 118 commits

What's in (Done)

Backend (server/)

  • 3NF SQLite schema (sites/groups/items/item_links/tags/item_tags/config) with WAL + foreign keys + busy timeout
  • Repository-pattern data layer (NavRepo, ConfigRepo traits + sqlx impls); offline .sqlx/ metadata committed for reproducible builds
  • Public GET /api/nav returns full NavBundle (camelCase, zod-mirrorable)
  • Single-admin auth: bcrypt cost 12 + signed cookie session via tower-sessions (SQLite store), 30-day sliding expiry
  • Login rate limited (tower-governor, 5 burst per 15 min)
  • Bootstrap: env BOOTSTRAP_ADMIN_PASSWORD or auto-generated 24-char password written to INITIAL_PASSWORD.txt (auto-deleted on first
    password change)
  • CRUD endpoints for items/groups/sites/tags/config (all RequireAuth)
  • Icon upload endpoint (POST /api/icons/upload, multipart, ≤1 MiB, ext-allow-list)
  • Favicon proxy with 7d disk cache (GET /api/favicon?host=…)
  • ServeDir + SPA fallback (Rust serves the SPA + /api/* from one process)
  • CLI: navsrv reset-password [--password=…] invalidates all sessions
  • 40 tests (lib + 16 integration suites), cargo clippy --all-targets -- -D warnings clean

Frontend (web/)

  • Svelte 5 (runes API: $props / $state / $derived / $effect / $bindable)
  • SvelteKit 2 + Vite 5 + adapter-static, ESLint 9 flat config, Prettier 3, pnpm 10
  • zod schemas mirroring backend NavBundle; every apiClient response zod-validated
  • Self-implemented i18n (~80 lines, no library; zh + en flat keys with {{name}} interpolation)
  • Modern-minimal design tokens (light + dark + reduced-motion)
  • Theme store (system / light / dark, persisted, syncs to prefers-color-scheme)
  • 10 UI primitives: Button / IconButton / Input / Dialog / Toast (+ ToastViewport + store) / Menu / Chip / Switch / Card
    / Skeleton
  • 4 stores (navData / uiPrefs / session / visibleSections derived) + editModeStore
  • New Header (Brand + SearchBar with / focus + TagFilterChips + SiteSelect + ThemeToggle + LocaleToggle + AuthControls)
  • New NavGrid (NavItem button-based + GroupHeader collapsible + GroupSection + FavoritesSection + EmptyState)
  • Footer rewrite (locale-aware filings; non-zh locales hide PRC ICP/police)
  • Edit mode (gated by auth): EditToggle + visual indicator + right-click context menu + ItemEditDialog (create/edit) + NewItemAffordance +
    ChangePasswordDialog
  • 31 unit tests (vitest), pnpm check / build / lint clean

Deploy

  • Multi-stage Dockerfile (web build + server build → gcr.io/distroless/cc-debian12, ~70 MB image)
  • docker-compose.yml + Caddyfile (auto-TLS via Let's Encrypt)
  • scripts/docker-smoke.sh (build + run + health/nav/login/me curl probes)
  • Playwright e2e (tests/read.spec.ts + tests/edit.spec.ts, port 18080, chromium-only) + scripts/e2e.sh harness

Repo layout

navigation_website/
├─ web/         SvelteKit SPA (self-contained: package.json, svelte.config, vite.config, tsconfig)
├─ server/      Rust crate (self-contained: Cargo.toml, build.rs, migrations/, src/, .sqlx/)
├─ tests/       Playwright e2e (root-level, runs against the Docker image)
├─ scripts/     dump-bootstrap.mjs, docker-smoke.sh, e2e.sh
├─ docs/superpowers/specs/   Design spec
├─ docs/superpowers/plans/   5 implementation plans
├─ Dockerfile + docker-compose.yml + Caddyfile
└─ README.md  Quickstart + dev workflow + env + architecture + roadmap

Test plan

Required (already verified in CI-equivalent local runs)

  • cd server && SQLX_OFFLINE=true cargo test40 tests pass
  • cd server && cargo clippy --all-targets -- -D warnings — clean
  • cd server && cargo fmt --check — clean
  • cd web && pnpm check — 0 errors
  • cd web && pnpm test:unit31 tests pass across 7 files
  • cd web && pnpm build — succeeds
  • cd web && pnpm lint — clean
  • Backend smoke: cargo run/api/health 200, /api/nav returns seeded bundle, login → cookie → /api/auth/me returns
    {authenticated: true}
  • Frontend smoke: pnpm dev proxies /api to :8080, page renders without errors

Deferred (Docker daemon was offline during the implementation session — REVIEWERS PLEASE RUN)

  • bash scripts/docker-smoke.sh — builds image, runs container, hits all endpoints
  • bash scripts/e2e.sh — full Playwright e2e against the Docker image (read + login + create item)
  • docker compose up -d (with DOMAIN=localhost) — Caddy fronts the container

Manual smoke (recommended)

  • docker run -d --name nav -p 8080:8080 -v ./data:/app/data -e BOOTSTRAP_ADMIN_PASSWORD=changeme navsrv:latest
  • Visit http://localhost:8080 — see seeded 17 items across 4 groups
  • Toggle theme (light/dark/system); toggle locale (zh/en); switch region (上海/北京/广州/深圳)
  • Click Log in, enter password, click Edit, right-click an item → Edit/Delete; click + card → fill form → Save; click 🔑 →
    change password
  • After password change, verify INITIAL_PASSWORD.txt is auto-deleted from data/
  • docker exec -it nav navsrv reset-password --password=newpw — invalidates sessions

Architecture decisions worth calling out

  1. Single Docker image, single process. Rust binary serves the SPA via tower-http::ServeDir with SPA fallback + /api/*. No nginx, no
    separate frontend container. Volume /app/data holds SQLite + uploads + INITIAL_PASSWORD.txt — container is stateless.
  2. Zod schemas as the single source of truth on the frontend. TS types are z.infer<typeof Schema>. Backend serde structs are
    hand-maintained alongside (kept in sync at PR review time — small repo, low friction).
  3. No UI library. Hand-built primitives in web/src/lib/components/ui/, all driven by CSS custom properties from tokens.scss. Each
    primitive is one file, <button>-based for a11y, focus-visible outlines, keyboard navigable.
  4. Self-implemented i18n (~80 lines). zh + en flat key dictionaries, t() is a Svelte derived store, {{name}} interpolation. No
    library.
  5. Optimistic mutations + zod-validated re-fetch on error. No server-driven SSE; the data is small and writes are rare (single admin).
  6. Cookie name flips on secure_cookies. Plain sid over HTTP (dev), __Host-sid over HTTPS (prod). RFC 6265bis–compliant.
  7. pnpm 10 + lockfile v9 pinned via web/package.json packageManager so corepack picks the right pnpm regardless of host. No more
    "ERR_PNPM_LOCKFILE_BREAKING_CHANGE" surprises.

Out of scope (Roadmap)

The following are explicitly NOT in this PR (documented in README's Roadmap section):

  • Drag-and-drop reorder of nav items
  • Full management UI for groups / sites / tags (currently editable via API only — direct curl or via ItemEditDialog's group selector)
  • Site settings dialog (站名 / avatar 上传 / 备案 / 默认主题 — all editable via direct API today)
  • Icon upload UI (backend endpoint exists; UI is future)
  • Multi-user / OAuth / audit log
  • Real-time multi-device sync (SSE)
  • PWA / offline
  • Healthcheck pinging individual nav links

Each can be a small follow-up PR; the spec already captures the data model and the design system supports the new screens.

Migration notes for self-hosters running the legacy nginx version

This PR is a from-scratch redesign — there's no automatic migration from the old src/lib/constants/nav.ts. Two paths:

  1. Fresh start (recommended): the new image ships with bootstrap.json containing 17 default items across 4 groups
    (network/media/nas/tools). Edit them via the UI after first login.
  2. Manual port: edit scripts/bootstrap-source.ts, run node scripts/dump-bootstrap.mjs to regenerate server/bootstrap.json, then
    rebuild.

Existing nginx Docker volumes (with cert files) are not consumed by the new image. Plan to re-issue certs via Caddy or use your own reverse
proxy.

- specs/2026-05-19-rust-navigation-platform-design.md
  Architectural redesign: Rust (Axum + SQLite + tower-sessions) backend,
  SvelteKit SPA front, single Docker image. New design system, i18n,
  3NF data model, edit mode UX. All open questions resolved per
  industry best practice.
- plans/2026-05-19-plan-1-rust-backend.md
  33-task TDD plan for the server/ crate end-to-end.

Plans 2-5 (frontend foundation, read-only, edit mode, deploy) to follow
after Plan 1 lands.
@bitxwave bitxwave force-pushed the feat/rust-platform branch from fe1b665 to 4614d73 Compare May 20, 2026 11:36
bitxwave added 6 commits May 20, 2026 19:44
Single-process Rust binary serving JSON API + SPA.
- 3NF SQLite schema (sites/groups/items/links/tags/config) with WAL +
  foreign keys + busy timeout
- NavRepo + ConfigRepo trait + sqlx impl; .sqlx offline metadata committed
- /api/nav public read returns full NavBundle (camelCase)
- Full CRUD endpoints behind RequireAuth
- Single-admin auth: bcrypt cost 12 + signed cookie session via
  tower-sessions (SQLite store), 30d sliding
- Login rate limited (tower-governor 5 burst per 15min)
- Bootstrap admin password: env-var or auto-generated 24-char password
  written to INITIAL_PASSWORD.txt (auto-deleted on first password change)
- /api/icons/upload (multipart, ≤1 MiB, ext-allow-list)
- /api/favicon proxy with 7d disk cache
- ServeDir + SPA fallback (Rust serves SPA + /api/* in one process)
- CLI: navsrv reset-password [--password=…] invalidates all sessions
- Repo split: web/ + server/ subprojects
- 40 cargo tests; clippy --all-targets -D warnings clean; fmt clean
…Plan 1.5)

Frontend toolchain modernization. Visual zero-regression vs baseline.
- Svelte 3.58 → 5.1 (runes API: \$props/\$state/\$derived/\$effect/\$bindable)
- SvelteKit next → 2.x stable
- Vite 4.3 → 5.4
- ESLint 8 → 9 flat config; eslint-plugin-svelte (svelte3 deprecated)
- Prettier 2 → 3 + prettier-plugin-svelte 3
- pnpm 9 (lockfile v6) → 10 (lockfile v9), pinned via packageManager
- zod ^3.23 added for Plan 2 API contract validation
- 5 .svelte components rewritten for runes (auto-store, {@render})
- siteStore typing fixed for Svelte 5 stricter overloads
…(Plan 2)

Type-safe foundation, no nav data wired yet.
- zod schemas mirror NavBundle (single source of truth, z.infer types)
- apiClient with zod response validation + ApiError + 4 unit tests
- Self-implemented i18n (~80 lines, no library; zh + en flat keys with
  {{name}} interpolation, 7 unit tests)
- Design tokens (light + dark + reduced-motion) per modern-minimal
  direction (Linear/Raycast inspired)
- Theme store (system/light/dark, prefers-color-scheme + localStorage)
- 10 UI primitives (Button/IconButton/Input/Dialog/Toast/Menu/Chip/
  Switch/Card/Skeleton), all token-driven
- /_demo route exercising every primitive with theme/locale toggles
- vitest 2.1 added; 11 unit tests pass
…n 3)

Wire Plan 2 foundation to Plan 1 backend. Visitors can browse seeded data
with search / tag chips / region switch / theme / locale toggles.
- 4 stores: navData (fetch /api/nav, zod-validated) + uiPrefs (LS persist)
  + session (auth/me/login/logout) + visibleSections (derived: site×search×tags)
- New Header: Brand + SearchBar (with '/' focus shortcut) + TagFilterChips +
  SiteSelect (Menu-based) + ThemeToggle + LocaleToggle + AuthControls
- New NavGrid: NavItem (button-based, favorite star, icon-kind aware) +
  GroupHeader (collapsible) + GroupSection + FavoritesSection + EmptyState
- Footer rewrite (locale-aware filings; non-zh hides PRC ICP/police)
- +layout mounts ToastViewport + new Header/Footer
- +page renders real navData (loading/error/empty/sections)
- Deletes 11 legacy files (5 .svelte + 3 constants + siteStore + 2 utils)
- 28 unit tests; e2e smoke (cargo + pnpm + curl) verified
Smallest editor that makes the admin actually administrative.
- API wrappers (createItem/patchItem/deleteItem/changePassword)
- editModeStore (auth-gated; logout flips off)
- LoginDialog (handles 401 bad password / 429 rate limited)
- EditToggle button in Header
- Visual indicator (top accent bar + main outline) when edit mode on
- NavItem right-click ContextMenu in edit mode (edit + delete with
  optimistic update + refetch on error)
- ItemEditDialog (create + edit; links as JSON, tags as CSV — MVP)
- NewItemAffordance ('+' card shown after each group in edit mode)
- ChangePasswordDialog + 🔑 button in AuthControls
- e2e smoke: login → create item → /api/nav shows item
- 31 unit tests pass

Out of scope (deferred): drag-drop reorder, group/site/tag full UI,
SiteSettingsDialog, icon upload UI.
Production-ready single-image deploy.
- Real bootstrap.json (17 default items + 4 sites + 4 groups)
- dump-bootstrap.mjs + bootstrap-source.ts (regen script)
- Multi-stage Dockerfile (web + server → gcr.io/distroless/cc-debian12,
  ~70 MB final image)
- docker-compose.yml + Caddyfile (auto-TLS via Let's Encrypt)
- scripts/docker-smoke.sh (build + run + verify health/nav/login/me)
- Root Playwright config (port 18080, chromium-only)
- 3 e2e specs: read flow + edit flow + scripts/e2e.sh harness
- README quickstart-first (Docker run + dev workflow + env +
  architecture + roadmap)
- All cargo + pnpm gates green: 40 backend tests + 31 frontend tests +
  clippy + fmt + lint clean

Note: Docker daemon was offline during the implementation session.
All Docker artifacts written and statically verified. User to run
scripts/docker-smoke.sh + scripts/e2e.sh after starting Docker Desktop.
@bitxwave bitxwave force-pushed the feat/rust-platform branch 2 times, most recently from 9960709 to 3f282c7 Compare May 20, 2026 11:49
bitxwave added 20 commits May 20, 2026 20:31
4 fixes after the first real Docker dry-run on host:

1. Dockerfile: rust:1.79-slim → rust:1.88-slim
   `home v0.5.12` (transitive) requires edition2024 (rustc 1.85+);
   `time v0.3.47` requires rustc 1.88. The 1.79-slim base failed at
   `cargo build`. rust-toolchain.toml already pins channel="stable".

2. Dockerfile: dummy build also creates src/lib.rs
   server/Cargo.toml has both [[bin]] and [lib]; the dummy main.rs
   alone made cargo error out: 'couldn't read src/lib.rs'.

3. scripts/docker-smoke.sh: python f-string backslash bug
   Old form 'f\"{...len(b[\"sites\"])...}\"' fails on python3.12+
   ('f-string expression part cannot include a backslash'). Switched
   to .format().

4. web/src/app.scss: drop legacy purple-blue gradient body bg
   The old #root background-image + --text-color: #fff fought with
   the Plan 2 design tokens (--c-bg, --c-text). Header and Footer
   used tokens; main background still used the gradient → the head/
   body palette clashed visibly. app.scss now just sets box-sizing,
   body margin, and a token-driven scrollbar; tokens.scss + Plan 3
   components own all colors.

Verified end-to-end: docker build OK; container hits health, nav,
login, me; SPA renders with consistent palette in both light and
dark themes.
…ch + ContextMenu

User feedback after first Docker dry-run:

1. Search bar now truly viewport-centered: header grid columns
   1fr/auto/1fr (was auto/1fr/auto), with explicit justify-content
   on .left/.right.
2. Region (SiteSelect) menu fixed: Menu's window-click-outside used
   onclick on the same event flow that opened it, immediately closing.
   Switched to mousedown listener attached inside $effect with a
   one-tick "armed" guard. NavItem ContextMenu also benefits — Edit
   menu item now actually fires the dialog (verified end-to-end).
3. Favorite star button hidden in edit mode (fav UX is read-only mode
   only; editing has its own context menu/dialog).
4. (was: 只能新建不能编辑) — same Menu fix above; right-click NavItem →
   Edit → ItemEditDialog opens with target pre-filled. Verified.
7. Restored the warm-coral × cool-indigo radial gradient as ambient
   #root background, and made Header/Footer frosted glass via
   color-mix surface + backdrop-filter blur, so they sit on top of
   the gradient without clashing. Dark theme uses violet/indigo
   gradient.

Verified via headless Playwright: site switch, fav hidden in edit,
right-click → Edit → dialog with pre-filled name all pass.

Out of scope, deferred to next round (per user list):
- #5 layout mode (flat vs grouped) with backend config UI
- #6 drag-drop reorder
… reorder

Round 2 of UX feedback (#5 + #6):

#5 Layout mode (backend-configurable)
- server/src/dto.rs: Meta gains layout_mode: String (camelCase wire).
- server/src/services/bundle.rs: read config["layout_mode"], default "grouped".
- web/src/lib/types/nav.ts: MetaSchema layoutMode = z.enum(['grouped','flat']).catch('grouped').
- web/src/lib/stores/visible.ts: visibleFlatItems (derived flatten of sections),
  layoutMode (derived from bundle.meta).
- web/src/routes/+page.svelte: branch on $layoutMode → NavGrid (flat) vs
  GroupSection per group (grouped).
- web/src/lib/components/Editor/SettingsDialog.svelte (new): radio group for
  layout, PATCH /api/config + navDataStore.refetch().
- web/src/lib/components/Header/AuthControls.svelte: ⚙ Settings IconButton +
  refactor 🔑 to IconButton (so aria-label is set; previous Button-based 🔑
  had no accessible name).

#6 Drag-and-drop reorder
- web/package.json: + svelte-dnd-action ^0.9.69
- web/src/lib/api/nav.ts: + reorderItems(entries) + patchConfig(pairs).
- web/src/lib/components/Nav/NavGrid.svelte: in edit mode, wrap grid with
  dndzone; on finalize, optimistic update navDataStore + POST /api/items/reorder;
  rollback on error. dragDisabled = !$editModeStore (unauthed/read-only).
- web/src/lib/components/Nav/GroupSection.svelte: pass groupId through to
  NavGrid so reorder payload is correct per-group.

Verified end-to-end via Playwright:
  - default = grouped (4 group headers visible)
  - login → ⚙ Settings → switch flat → save → page now single grid of 17 items
  - switch back to grouped → 4 group headers return
UI overhaul to bring the design back to master's vibrant identity while
keeping the new edit-mode / i18n / dark-mode functionality.

Visual language
- Restore master's red→blue diagonal gradient as the page canvas; remove
  per-section frosted bars (header / footer) so the gradient flows top to
  bottom uninterrupted.
- Card primitives: 96→120px, soft 0 0 22px shadow, 22px radius, no border;
  bold white labels with text-shadow; shake-bounce on hover.
- Header & Footer fully transparent (no backdrop-filter, removing the
  stacking context that was clipping child Dialog fixed positioning).
- Header inner full-width: brand pinned left, search pill centered,
  controls pinned right; icons unified to 18px monochrome SVGs (theme,
  globe, gear, key, pencil edit-toggle).
- Search input promoted to a clear glass pill with stronger border.
- Menu / Dialog / Toast all get the same frosted glass treatment so popups
  read as one family with the rest of the page.

Launchpad folder mode
- Grouped layout now renders each group as a folder tile (120x120 glass
  card with 2x2 mini-icon preview + group name); clicking opens a
  full-screen frosted modal with that group's full NavGrid inside.
- New components: Nav/GroupFolder.svelte, Nav/GroupFolderModal.svelte.
- Search bypasses folders and flat-lists matching items.
- Flat mode unchanged.

Layout / spacing
- Main is top-aligned with padding-top 100px so content sits below the
  sticky transparent header with breathing room.
- Favorites section hidden when search/tag filter is active and in flat
  mode (favorites are a grouped-only feature).

Editor improvements
- ItemEditDialog: links go from JSON textarea to a structured row editor
  (site dropdown + URL + remove + Add link); icon picker grid for the
  asset kind, fed by a build-time manifest generated by a small vite
  plugin (web/static/navIcons-manifest.json).
- SettingsDialog: bigger (md) width; expose site_name / site_avatar_path
  / site_copyright as inputs; layout-mode picker becomes two preview
  cards with mini mockups; diff-only patch on save; hydrate-once guard
  so navData refetches don't wipe in-flight edits.

Smaller fixes
- SiteSelect / LocaleToggle dropdowns mark the active item with ✓.
- LocaleToggle is now a real menu (中文 / English) instead of a one-shot toggle.
- Cancel ghost button in dialog footers gets a visible border + soft fill
  so it doesn't melt into the glass.
- Favorite star is hidden by default, appears on cell hover, uses outline
  vs filled SVG to encode state without a chip background.
- NewItemAffordance lives inside NavGrid (display:contents trick) so it
  sits as the trailing cell of the same row, not a separate row.

Build / chore
- vite plugin scans web/static/navIcons/ on configResolved and writes
  navIcons-manifest.json; .gitignore + .prettierignore updated to ignore
  the generated manifest and local-data/ dev sqlite.
Intranet self-host is the primary deployment shape, so simplify the bundle:

- Remove Caddyfile and the caddy service from docker-compose.yml; nav now
  publishes ${PORT:-8080}:${PORT:-8080} directly with SECURE_COOKIES default
  flipped to false.
- Document the single PORT variable (covers both container-internal listen
  port and host mapping) in README, server/README, and .env.example.
- README gains explicit Build (SPA / release binary / Docker image) and
  Deployment (compose / docker run / data persistence / optional reverse
  proxy) sections.

No code change in navsrv: PORT was already wired through Settings (server/
src/config.rs) and consumed in main.rs, this commit just exposes it through
the deploy surface.
…ockups

- repo/sqlx_impl.rs: create_item / patch_item used to do
  `INSERT INTO item_tags ... SELECT ... FROM tags WHERE slug = ?`,
  which silently inserted 0 rows for any unknown slug. The frontend
  passes slugs as free text from a CSV input, so unknown ones (e.g.
  "fav, tools" on a fresh DB) disappeared on save. Now we
  `INSERT OR IGNORE INTO tags (slug, name) VALUES (?, ?)` first and
  then link, so any new slug is persisted with its own tag row
  (name defaults to the slug; can be renamed later via /api/tags).
- tests/repo_items.rs: regression covers create_item and patch_item
  with previously-unregistered slugs.
- SettingsDialog.svelte: replace the CSS Grouped/Flat mockups with
  inline SVG so Grouped clearly shows "section header + tile row"
  per group instead of two thin horizontal bars; Flat is a uniform
  4×3 grid. Selected state colors stay accent-tinted.
…bullet)

The previous SVG used a filled circle + long title bar per section, but
GroupHeader.svelte renders a chevron (▸) plus a short group name. Match
that: each row shows a small right-pointing triangle and a short title
bar, then a row of tiles.
Actual Grouped mode in routes/+page.svelte is the Launchpad-style
folder grid (each group renders as a 120×120 rounded folder with a
2×2 thumbnail preview and label below — see GroupFolder.svelte), not
a chevron-headed list. Match that: 4 folder cells in a row, each
with a 2×2 dot grid inside and a small label underneath.
…hat would orphan data

Replaces the old SettingsDialog with a dedicated /admin page that is
the single home for everything except editing individual nav items.

Frontend
--------
- IconSourcePicker (lib/components/Editor): extracted from
  ItemEditDialog so the same picker handles bundled icons, custom
  URLs, auto-favicons, and a new local-file upload (POSTs to
  /api/icons/upload). Callers narrow what's exposed via allowedKinds.
- ItemEditDialog now uses IconSourcePicker; no UX change beyond the
  added "Upload local file" button under Custom URL.
- /admin route (routes/admin/+page.svelte) with four tabs:
    Site    — Branding (incl. avatar via IconSourcePicker; supports
              bundled or URL/upload), Layout mode picker
              (Grouped/Flat with the Launchpad mockup from the
              previous fix).
    Groups  — list + inline rename + delete; flags how many items
              each group still holds.
    Sites   — list + inline rename + delete + isDefault toggle;
              shows reference count and refuses delete client-side
              when references exist.
    Tags    — list + inline rename + delete; auto-created tags from
              the item editor's slug input show up here for renaming.
  Page guards against unauthenticated access by redirecting to /.
- AuthControls: gear icon now navigates to /admin instead of opening
  SettingsDialog. SettingsDialog is removed.
- New API helpers under lib/api/admin.ts (groups/sites/tags CRUD)
  and lib/api/icons.ts (uploadIcon).

Backend
-------
- delete_site now refuses with 409 Conflict when item_links still
  reference the site (previously CASCADE wiped the links silently).
- delete_group now refuses with 409 Conflict when items still belong
  to the group (previously the FK SET NULL silently orphaned them).
- Regression test: delete_site_referenced_by_item_returns_conflict.
The free-form tag chips were never made part of the final UX (the
header chip filter never shipped past dev), and the tags / item_tags
tables, the /api/tags endpoints, and the Tag admin tab were all dead
weight. Wipe them.

Backend
- Drop /api/tags route. Drop NavRepo::{list,create,patch,delete}_tag,
  Tag DTO, Item.tag_slugs, NavBundle.tags, ItemPayload.tag_slugs,
  ItemPatch.tag_slugs.
- create_item / patch_item no longer touch item_tags or upsert tag
  rows; the body schema is correspondingly slimmer.
- New 0002_drop_tags.sql migration drops item_tags and tags. Existing
  databases lose any orphan tag rows on next boot — by design, since
  no UI references them anymore.
- Bootstrap loader: drop BootstrapTag, drop the seeding step, drop
  per-item tagSlugs. bootstrap.json + scripts/bootstrap-source.ts
  cleaned up to match.
- Tests: drop tag_lifecycle, drop create_item_auto_creates_unknown_
  tag_slugs / patch_item_auto_creates_unknown_tag_slugs (those were
  added solely for the bug we just deleted), strip tag_slugs from
  remaining item fixtures.

Frontend
- Drop AdminTagsTab and the Tags admin route entry.
- Drop TagFilterChips, allTagSlugs, activeTagSlugs, toggleTag.
  hasActiveFilter is now searchQuery-only; clearFilters likewise.
- Drop Tag from types/nav.ts (TagSchema, NavBundle.tags,
  Item.tagSlugs). Drop createTag/patchTag/deleteTag from api/admin.ts.
- ItemEditDialog: drop the "Tag slugs (comma-separated)" input and
  the corresponding payload field.
- Tests updated; predictably failing navData.test fixture finally
  green now that the schema is unambiguous.
Reuses svelte-dnd-action (already a dep, drives NavGrid item reorder).
Each row gets a ⋮⋮ grab handle on the left; dropping a row triggers
POST /api/{groups,sites}/reorder with the new sortOrder for every
row in the list, then refetches navData. Drag is auto-disabled while
a row is being inline-edited or created (so input/drag don't fight)
and while a previous mutation is in flight.

API helpers reorderGroups / reorderSites added to api/admin.ts.
Captures the agreed design for replacing the dual grouped/flat layouts
with a single Launchpad canvas: unified `cards` table (folder | item),
long-press jiggle mode, drag-to-reorder/merge/extract folders, drop
all i18n columns from user data, drop the layoutMode toggle.

Implementation plan to follow via writing-plans.
Replace the legacy groups/items + item_links data model with a unified
cards(kind in folder|item) + card_links(card_id, site_id, url) bridge
table. The home grid is now a LaunchPad-style canvas: long-press 600ms
enters jiggle mode, in jiggle each card shows an ✕ for delete and is
draggable for reorder / merge into folders / drag out of folders.

Backend:
- migrations 0003 (cards + card_links) and 0004 (drop i18n cols)
- new routes/cards.rs: POST / PATCH / DELETE / reorder / auto-folder
- new services/legacy_migrate.rs: one-shot copy of groups+items into cards
- delete routes/groups.rs, routes/items.rs and their tests
- repo/sqlx_impl.rs and dto.rs rewritten around Card

Frontend:
- new lib/types/card.ts, lib/api/cards.ts
- new Card.svelte (391 LoC, near-complete: long-press, jiggle, ✕ confirm,
  inline folder rename, 2x2 mini-folder thumb, merge halo)
- new JiggleHost.svelte, InlineFolderExpand.svelte (modal-style expand)
- new util/dragGrid.ts (372 LoC hand-rolled Pointer Events action;
  ditches svelte-dnd-action because FLIP shifting breaks dwell detection)
- new stores/jiggle.ts, stores/dragMerge.ts
- delete NavGrid / GroupFolder / GroupFolderModal / GroupHeader /
  GroupSection / FavoritesSection / NavItem / EditToggle /
  AdminGroupsTab / editMode store
- routes/+page.svelte rewritten as the single dragGrid canvas wrapping
  root grid + open folder panel; handleDrop dispatches reorder /
  auto-folder / reparent / drag-out branches
- visible.ts now resolves per-site URL via card.links[siteValue],
  not by filtering whole cards
- ItemEditDialog rewritten with per-site link rows (one URL per site)

Multi-site semantics: a card binds to one or more sites, each site
carries its own URL via card_links. Switching site changes which URL
opens, not which cards exist.

Aligned with docs/superpowers/specs/2026-05-21-launchpad-unify-design.md.
LaunchPad clue §1: turn the single auto-fill root grid into horizontal
pages with a bottom dot indicator. Page size adapts to viewport
(desktop 7×5, narrow 4×6, mobile 4×5). Translation uses CSS
scroll-snap so trackpad / touch swipe works natively without fighting
the dragGrid pointer-capture session.

Interactions:
- Wheel / trackpad horizontal swipe / touch swipe → snap to neighbor page
- Click any dot → smooth scroll to that page
- ←/→ and PageUp/Down (when no input is focused) → step page
- New card submitted → pager jumps to last page so the result is visible
- Switching site → reset to page 1 (an old index makes no sense in the
  new card set)
- During a card lift, scroll-snap is disabled via `:has()` so the
  drag clone doesn't fight snap-to-page; cross-page dragging is
  intentionally not supported in P1

Files:
- new util/paginate.ts: chunk()
- new stores/pageStore.ts: currentPage writable + setPage/next/prev/reset
- new components/Nav/PageDots.svelte: dot indicator, hidden when 1 page
- routes/+page.svelte: wrap root grid in scroll-snap pager, mount the
  resize listener for pageSize, drive scrollTo from currentPage,
  observe scroll to write currentPage back, keyboard handler on window
- components/Editor/ItemEditDialog.svelte: optional onCreated callback
  fires after the data refetch so the caller can scroll-into-view
- i18n: home.pager.aria

Validation: pnpm check passes for new files (3 pre-existing errors in
vite.config.ts unrelated). pnpm lint: 0 errors in new files
(uiPrefs.ts unused-import is pre-existing). Smoke test via Playwright:
homepage renders, no console errors, pager DOM exists at all
breakpoints, dots correctly absent when only 1 page. Multi-page
turning still needs human verification with ≥36 real cards.
LaunchPad clue §1: while dragging a card in jiggle mode, holding the
cursor near the viewport left/right edge for ~600 ms now turns the
pager and re-fires every 800 ms, so a card can be dragged across
multiple pages. Drop semantics are unchanged (handleDrop dispatches
reorder / merge / reparent against whichever target ends up under
the cursor on the new page).

dragGrid.ts:
- new EDGE_PAN_THRESHOLD_PX (80), EDGE_PAN_DWELL_MS (600),
  EDGE_PAN_INTERVAL_MS (800)
- DragGridOptions: optional onEdgePan(direction) callback alongside
  onSpringLoad
- DragSession: edgePanTimer + edgePanDirection state
- detectEdgePan() reads cursorX vs window.innerWidth
- applyEdgePan() runs after applyHover() each tick: starts a self-
  rescheduling setTimeout chain on entry, cancelEdgePan() on exit
- finish() clears edgePanTimer alongside the existing dwellTimer

+page.svelte:
- onEdgePan(direction) → currentPage.prev / next(pageCount-1)
- pass onEdgePan to dragGrid options
- CSS: drop the `overflow-x: hidden` from the dragging-state .pager
  rule. scrollTo() (driven by P1's $effect when currentPage changes)
  now turns pages during a drag. The user still can't accidentally
  scroll because dragGrid setPointerCapture owns the gesture.

Validation: pnpm check passes for new code (3 pre-existing vite.config
errors unrelated). pnpm lint: 0 new errors. Playwright smoke verifies
homepage still renders cleanly with no console errors. Full cross-page
drag still needs human verification with ≥36 real cards.
Replace the header SiteSelect dropdown on the home route with an
inline horizontal pill row at the top of the page. Two controls
sharing one currentSite store would have been a duplicate, so the
header dropdown is hidden specifically when pathname === '/'; other
pages (admin etc.) keep it.

The pills follow LaunchPad clue §11.1 visuals: glass surface,
squircle shape, theme-color fill + lift on the active pill, white-text
for contrast on the gradient. ←/→ moves selection while focused; the
row scrolls horizontally if there are more sites than fit.

new components/Nav/SitePills.svelte
- pills hide automatically when there is only 1 site (degenerate case)
- backed by uiPrefs.setSite + currentSite store (no new state)
- aria-pressed (not role=tab) so the <nav> stays correct in a11y tree
+page.svelte: render <SitePills /> above all branches so it's visible
in launchpad / search / empty / loading.
Header/index.svelte: hide <SiteSelect /> on home via $page.url.pathname.
i18n: home.site.aria.

Validation: pnpm check + pnpm lint pass for new files (3 + 1
pre-existing errors unrelated). Playwright smoke confirms 4 pills
render with 1 active state, header dropdown is gone on /, no console
errors.
LaunchPad clue §5.3: when the user is already in jiggle mode and
clicks the body of an item card (not the ✕, not the pencil), open
the edit dialog. Previously this was a no-op — the rationale was to
avoid the long-press release immediately popping the editor; now
that's solved by gating on a 350 ms grace window after enter() so
the long-press tail still no-ops as before.

stores/jiggle.ts: new enteredAtStore writable + readable export
jiggleEnteredAt. enter() records Date.now(), exit() resets to 0.

components/Nav/Card.svelte: open() in jiggle mode now reads the
enteredAt timestamp; clicks within 350 ms are treated as the
long-press release (no-op for items, expand for folders, same as
before); clicks after that window route to onEdit for items.

The pencil edit-button is kept as an explicit affordance for
discoverability — body-click is the iOS-style shortcut.

Validation: pnpm check + pnpm lint pass for changed files. Smoke
verifies pills + grid still render with no console errors.
LaunchPad clue §11.2 / §5.1: when an authenticated user lands on a
site with no cards yet, the grid shouldn't render as a blank
canvas — it should surface the same dashed-+ placeholder that jiggle
mode shows, plus a one-line hint, so the first add is one click
away (no need to enter jiggle first, since there's nothing to
long-press).

Visibility rule for the placeholder cell on the last page:
  authed && (jiggleMode || rootCards.length === 0)

When the grid is also empty, an extra hint paragraph spans the full
grid row below the placeholder, "Click + to add your first card"
(localised). Unauthenticated viewers still see the existing
EmptyState branch — they have no permission to add anyway.

i18n: home.empty.hint.

Validation: pnpm check + pnpm lint pass for changed files. Smoke
on the populated site still renders normally with no console errors.

(P5 path-map originally bundled keyboard Tab cycling and long-press
Space preview; both moved to P6 so this commit stays single-purpose.)
LaunchPad clue §4 / §10 visual polish:

- Jiggle phase stagger. Previously every card jiggled in lock-step,
  which reads as a global animation rather than per-cell motion.
  Three CSS phases (0 / -80 / -160 ms) cycle across nth-child(3n),
  spreading the 250 ms shake period evenly without any JS.
- Hover lift on the home cards in non-jiggle mode: translateY(-2px)
  + the existing shadow swap, so a mouse user gets a subtle "this is
  clickable" feedback. The lift is suppressed inside .cell.jiggle
  (the rotate keyframe owns the transform there; an extra translate
  would fight it). The folder tile gains the same transition.

No behavior change. pnpm lint passes for the changed file. Smoke
unchanged.
Wrap-up phase. The home placeholder pager / dragGrid / pills /
jiggle-edit / empty-grid / phase-stagger work (P1-P6) is in. This
commit only does:

1. SitePills tabindex: only the active pill keeps tabindex 0; the
   others are -1. So the chip row is a single Tab stop — once
   focused, ←/→ moves between pills (already wired). This matches
   §11.1 "Tab cycles between chip / search / grid".

The accompanying plan-file §15 (kept outside the repo, in
~/.claude/plans/) records the §12 "intentionally not done" check,
the per-phase scope-discipline self-check, and the human-verification
checklist that still wants real data (≥36 cards, an admin login).

Long-press Space URL preview from plan §8 is explicitly skipped — it
is marked "optional" there and would need a new tooltip component.

Validation: pnpm lint passes for the changed file (uiPrefs.ts unused
import is pre-existing, unrelated). Smoke unchanged.
bitxwave added 30 commits May 26, 2026 12:08
Adds layoutCache, sourceLogicalIdx, and dwell timers/state to
DragSession. Introduces startMergeArmForItem / setMergeImmediateForFolder
/ cancelMergeArm. Not wired into applyHover yet — Task 5 does that.
…ate null pulse

setMergeImmediateForFolder previously called cancelMergeArm which set
mergeCandidate to null before re-setting it to ready in the next
synchronous statement. Subscribers (especially eager ones) saw a
spurious null transition on every folder hover. Pull the timer-clearing
logic into clearMergeArmInternal and call it from
setMergeImmediateForFolder, leaving mergeCandidate alone so the store
transitions directly from prior state to ready.

Per code review on Task 4 (commit 03de5fd).
liftSource builds the per-session layout cache. applyHover drives the
dwell state machine (item: 200/600 ms two-stage; folder: instant) and
republishes cellShifts each tick. mergeCandidate now includes phase.
publishShifts runs per rAF tick and previously published a fresh Map
to cellShifts every time, triggering reactivity in every Card
subscriber even when shifts hadn't changed. Add shiftsEqual + setShifts
helpers that skip writes when value-equal to the last published map.

Also pre-build layoutCache.bucketsRecord once at lift instead of
rebuilding the Record from byZone every tick.

Per code review on Task 5 (commit 83a060e).
resolveFinalIntent: dropping on an item where the dwell never fired
falls back to before/after by cursor side. Releasing in armed/ready
state commits merge. Folder targets always merge.
Card subscribes to cellShifts and applies translate transform; merge
halo splits into merge-armed (faint, 200ms) and merge-ready (full,
600ms). Source cell goes invisible while dragging so the gap is shown.
… scope data-dragging selector, export SHIFT_DURATION_MS

Three follow-ups to commit d9d540a per code review:

- Jiggle keyframes own `transform: rotate(...)` on the same inner
  element our merge-armed/ready rules target, so during drag the merge
  scale was effectively dead. Pause `animation-play-state` on those
  states so the static scale renders.
- Mark `[data-dragging='true']` :global so Svelte's static analyzer
  doesn't flag the imperatively-set attribute as unused. Keeps `.cell`
  scoped to this component.
- Export SHIFT_DURATION_MS so it's not flagged unused; +page.svelte can
  later wire it into the `--shift-duration` CSS var.
- Reflow mergePhase $derived to a single line per Prettier.
Layout-changing events mid-drag invalidate the cache; expose
rebuildLayoutCache() from the dragGrid action and call it from
+page.svelte after tick().
… between-cards drops

Two related bugs surfaced after Task 7 added the visible reorder shift:

1. Same-zone, src-before-target: handleDrop computed
   `insertAt = effectiveIntent === 'before' ? targetIdx : targetIdx + 1`
   directly off the target's idx in the pre-removal bucket. But
   reorderEntries operates on the post-removal list, where target's
   idx shifts up by 1 once source is filtered out. Result: dropping
   "before C" with source A actually placed A after C.
   publishShifts had the same off-by-one in its dropIdx formula.

2. Cursor in the gap between two cards (no DOM cell hit) silently
   no-op'd: handleDrop's branches all required `info.target`, and
   publishShifts fell back to `dropIdx = bucket.length` (append at
   end). Both wrong: the gap IS the natural reorder target.

Fix:
- SlotRect now carries `kind`, populated by buildLayoutCache.
- publishShifts always picks the closest non-source slot from the
  cached layout and computes dropIdx via cursor side + post-removal
  adjustment. No longer relies on elementsFromPoint hitting a cell.
- resolveFinalIntent synthesizes a target+intent from the closest
  slot when none was hit, so handleDrop has something to anchor on.
- handleDrop applies the same post-removal correction to insertAt.

Tests stay 39/39 (computeShifts math was already correct; the bugs
were in the dropIdx callers).
…erge threshold

1. Flicker on drop. cellShifts cleared in finish() while reorderCards
   API was still in flight, so cards transitioned from shifted back to
   0 over 220ms while handleDrop's refetch concurrently rerendered
   them in the new sort order — visible snap+wobble. Suppress the
   transform transition for two rAF frames around drop cleanup so the
   snap is instant; refetch + DOM reorder happens during the
   suppression window. Restored on the third frame.

2. Folder source no longer triggers merge halo. Dragging a folder over
   another folder or item is always reorder (folders can't nest). The
   dwell state machine in applyHover now gates both the item-arm and
   the folder-instant paths on `source.kind === 'item'`, so folder
   sources never set mergeCandidate.

3. Merge zone is now overlap-based, not cursor-position-based. The old
   "cursor in inner 50%" rule fired whenever the cursor brushed the
   middle of a card, even when the dragged card barely touched the
   target. Replaced with a real geometric check: the dragged card has
   to overlap the target by ≥ MERGE_OVERLAP_FRACTION (0.85) of the
   smaller card's area before classifyIntent returns 'merge'. Tunable
   constant; user asked for a much stricter threshold than before.

classifyIntent signature change: now takes (target, dragged, cursorX)
instead of (target, cursorX, cursorY). computeHover constructs the
dragged rect from cursor + cloneOffset + cached source dimensions.
…d reorder

Animation delay was assigned via :nth-child(3n+2)/:nth-child(3n) CSS
selectors. When a drag commit reorders the grid, every card's
nth-child position changes — and so does its animation-delay — which
yanks the wobble to a new phase mid-stride. Visually this reads as a
flicker on either the dropped card or a neighbor, regardless of the
transform-transition suppression added in 9deb642.

Move the delay onto a per-card CSS variable (--jiggle-delay) computed
from card.id (a stable identifier) and exposed via style:--jiggle-delay
on the cell. Each card now keeps its own continuous wobble phase
across reorders.
… resolves

The flicker after a successful drop was a coherence problem, not an
animation problem. With the previous code:

  1. finish() fires onDrop and synchronously clears cellShifts +
     data-dragging.
  2. Cards snap to their pre-drag logical positions (no transition,
     transform = 0). Source becomes visible at its old slot.
  3. ~200ms later, reorderCards completes, navDataStore refetches,
     Svelte re-renders the each block with the new sort order.
  4. Cards snap from old positions to new positions.

Steps 2 and 4 are TWO visible state changes in quick succession —
that's the flicker the user reported, and what the screen recording
shows on both the dropped card and a neighbor.

Make the visual cleanup conditional on the onDrop Promise:

  - During the await window, data-dragging stays on (source hidden,
    its slot acts as the visible gap), cellShifts stays in place
    (neighbors stay in their shifted positions). The visual matches
    the pre-drop preview, so the user sees no change yet.
  - When handleDrop's promise resolves, navDataStore has been
    refetched synchronously inside the same microtask. We then clear
    visual state. Svelte's render flush sees both the new data AND
    the cleared shifts in one pass, so the cards land directly at
    their new positions with no intermediate frame.

DragGridOptions.onDrop is now `void | Promise<void>` to declare the
contract. Existing void consumers stay compatible — the cleanup runs
synchronously when no Promise is returned.
When a real card was shifted past the last card during a reorder
preview, the "+ new item" affordance stayed put — the displaced card
visually overlapped the affordance instead of pushing it forward.

Fix: include the affordance container as a phantom slot in the
layout cache. It is marked with `data-add-cell`; buildLayoutCache
appends one slot per affordance to its zone with cardId =
ADD_CELL_CARD_ID (-1) and a kind of 'item'. computeShifts treats it
like any other slot, so a card landing at logicalIdx == affordance's
slot causes the affordance to translate to the next CSS-grid slot.

resolveDropIdx and resolveFinalIntent skip phantom slots — the
affordance is not a valid drop target. The page reads
cellShifts.get(ADD_CELL_CARD_ID) and applies the same translate
transform as Card.svelte does for real cards, with a matching 220ms
cubic-bezier transition so the motion is uniform.
…fting it

Previous attempt (commit 7e2238e) treated the affordance as a phantom
slot in the layout cache and let computeShifts translate it. That
worked for in-row shifts but failed at row boundaries: cellAdvance
extrapolates by one column horizontally, which pushes the affordance
past the grid's last track in CSS auto-fill grids. With
`justify-content: center` on .grid the result was an off-grid
position clipped at the viewport's right edge — exactly the
half-visible affordance the user reported.

Drop the phantom-slot approach. The affordance is purely a UI
shortcut (it isn't a drop target and the user can't click it
mid-drag), so the simplest correct behavior is to hide it visually
for the duration of the drag and let the natural CSS-grid auto-flow
handle its position both during (invisible) and after (re-shown at
the end of the new card list).

`visibility: hidden` (not `display: none`) preserves the affordance's
CSS-grid slot so the layout cache rect stays consistent with what
the user lifted on. The CSS rule is gated on
`.canvas:has([data-card-id][data-dragging='true'])` so it only
applies during an active lift. Removes the now-unused
ADD_CELL_CARD_ID export and the phantom-slot logic in
buildLayoutCache, resolveDropIdx, and resolveFinalIntent.
… class

InlineFolderExpand unmounts when the cursor leaves its zone (handled
by onHoverZoneChange in +page.svelte). The unmount detaches the source
cell carrying [data-dragging='true'] from the DOM, so the
:has([data-card-id][data-dragging='true']) descendant selector loses
its match — silently disengaging the .add-cell visibility-hidden,
.pager scroll-snap-type:none, and .canvas touch-action:none rules
mid-drag.

Bind .canvas's drag-active class to \$dragSource (set on lift, cleared
on drag end by dragGrid) so the gate is anchored on a stable store
signal that outlives any descendant unmount. The four \$global :has(...)
selectors collapse into the equivalent .canvas.drag-active local rules.
1. Spring-load drops the source.zone === 'root' restriction. Dragging
   a child card from one open folder to another folder card in root
   now opens the destination on dwell, same as root → folder. The
   pre-existing comment said "spring-load only when src came from
   root"; with the source's panel covering root via fixed-position
   z-index 80+, elementsFromPoint can't accidentally hit a root
   folder card behind it anyway, so the gate was over-restrictive.

2. Merge halo gates on target.zone === 'root' (in addition to the
   pre-existing kind / source.kind checks). Inside an open folder
   panel a "merge" cursor is ambiguous — no nested folders, every
   child already lives in a folder — and +page.svelte's drop
   handler coerces merge → before/after there. Mirroring that here
   stops the blue ring from lying about what release will do.

3. mergeCollapse's setShifts(new Map()) replaced with
   computeSourceGapClose(session). The source cell stays in its
   CSS-grid slot at opacity:0 (so layoutCache rects stay stable),
   and clearing every shift exposed that hidden slot as a 6×-wide
   visible gap between visible siblings. The new helper shifts
   every same-zone slot whose logicalIdx > source's by one position
   back, mirroring the close-gap arm of the cross-zone branch in
   computeShifts but without opening any insertion gap.

4. Source dimensions cached at lift (sourceLiftWidth/Height on the
   session) and used by computeHover instead of
   layoutCache.byCardId.get(source.id)?.rect. After spring-load
   triggers rebuildLayoutCache, the source's folder panel has
   already been unmounted by onHoverZoneChange so the rebuilt
   cache has no entry for source.id; the old fallback gave 0×0
   draggedW/H, classifyIntent's overlap area never reached
   MERGE_OVERLAP_FRACTION, isMergeFolder/isMergeItem never tripped,
   and a release on a folder card silently reordered next to it
   (branch 2 in handleDrop) instead of reparenting (branch 1).

5. Cross-zone target extrapolation in computeShifts now reads CSS-
   grid colCount + rowGap to wrap to row N+1 col 0 when newIdx
   crosses a column boundary. Previously cellAdvance gave a single
   horizontal step, so dropping into a folder whose last visible
   row was already full pushed the trailing card off-grid right and
   the browser scrolled the container horizontally until release.
   buildLayoutCache resolves geometry per zone via
   getComputedStyle(gridEl).gridTemplateColumns (auto-fill resolves
   to an explicit "120px 120px ..." list) and stores it on
   LayoutCache.gridGeometryByZone. ComputeShifts gains an optional
   gridGeometryByZone input so existing single-row unit tests
   continue to exercise the legacy col-step path.
InlineFolderExpand previously centred .wrap with top:50% +
translate(-50%,-50%); for sparse folders that left a large empty
band above the title and made the panel float low on the viewport.
Switch to top:120px + translateX(-50%) so few-card folders show up
near the top, full folders still fill downward up to the
calc(100vh - 120px - sp-7) max-height clamp.

JiggleHost.host's background-click handler exited jiggle mode when
e.target === e.currentTarget. The browser dispatches click on the
*common ancestor* of mousedown- and mouseup-targets, so drag-
selecting a card label and releasing between cards triggered the
handler with target === currentTarget and exited jiggle mid-
gesture. Track the pointerdown origin and only exit when the press
also began on .host.
Two fixes in the shared Dialog component:

1. Remove the backdrop pointerdown/click listeners. Clicking the
   dimmed area was dismissing dialogs mid-edit — both via accidental
   pointer slips and via the synthetic-click-on-common-ancestor
   effect when a user drag-selected text inside an input and mouseup
   landed outside the dialog. Forms with multiple fields and
   unsaved input were worst-affected. Esc and the consumer-rendered
   footer Cancel button remain the only close paths.

2. Footer ghost button text was rendering invisible against the
   dialog's glass surface because Header.svelte's
   .header :global(.btn.intent-ghost) { color: var(--c-card-label) }
   (white text for buttons over the public site's gradient) bleeds
   into LoginDialog — the dialog is rendered as a DOM descendant of
   <header> via AuthControls. Raise the Dialog footer ghost rule's
   specificity to (0,5,0) by prefixing with .backdrop and then
   explicitly setting color: var(--c-text). Beats the header
   override regardless of cascade order; dark-mode counterpart
   updated to match.
Admin pages now opt out of the public Launchpad chrome (Header,
Footer, gradient backdrop) and render their own dashboard shell —
a 56px topbar with brand + tab breadcrumb + Locale/Theme toggles +
Logout, a 240px sidebar with Settings tabs, and a content card
body. Only --c-bg / --c-surface / --c-border / --c-accent tokens
used; no admin-specific colour palette introduced.

Layout detachment via routes/+layout.svelte: when the URL starts
with /admin, skip the public Header/main/Footer wrap entirely and
render children directly. body.admin-route flag in app.scss flips
#root's background from gradient to --c-bg.

i18n: 41 admin.* keys added to zh.json and en.json. Every label,
description, placeholder, toast, confirm, badge in the admin
chrome and tab bodies now reads via \$t().

Sites tab cleanup:
- Drop the user-facing Value (key) input. Sites carry an opaque
  identifier (referenced by card.links[siteValue]); exposing it as
  a writable input let users break referential integrity. Now
  generated client-side as s_<base36 ts><rand4> on create and never
  edited again.
- Display row hides the slug; edit row only changes name +
  isDefault; the patch payload omits value to make the
  immutability explicit.
- Save/Cancel align to the input box bottom (grid align-items: end
  + matching .def height) instead of floating at the row top.

Account tab (new) hosts password change, replacing the icon-
button + ChangePasswordDialog popup that lived in the public
header. ChangePasswordDialog deleted; AuthControls trimmed to
just Admin gear + Logout (or Login when unauthed). After a
successful password change the page redirects home — server
invalidates sessions on password update.
Adds the design contract and the task-by-task implementation plan
that drove the recent Launchpad UX rework — dwell-gated merge,
shift-to-make-room reorder preview, cross-zone shifts, and the
follow-up fixes (drag-active class, folder-source spring-load,
mergeCollapse source-gap close, wrap-aware extrapolation).

Spec: behavior contract, hover-state machine, layout cache, drop
intent resolution, manual + unit acceptance.

Plan: 9 tasks with code-level diffs and per-task git-commit
templates. Progress tracking via checkbox syntax.
Cargo.toml: package.name, default-run, [[bin]].name, [lib].name all
flipped to "lens". 14 test files + 1 src file have their `use
navsrv::` import paths updated. Three string literals also moved:
clap CLI program name (`#[command(name = "lens", version)]`),
tracing log line ("lens listening"), and the favicon HTTP user-
agent ("lens-favicon/0.1").

The seed test's bootstrap assertion now expects the new default
site name "Lens" — that change rides along here so cargo test stays
green within this single commit. Cargo.lock is regenerated by
cargo because the local package name changed.
Dockerfile: --bin navsrv → --bin lens (×2 across the cargo cache
layer + real build), target/release/navsrv → target/release/lens,
/usr/local/bin/navsrv → /usr/local/bin/lens, ENTRYPOINT updated.

docker-compose.yml: service key `nav:` → `lens:`,
image: navsrv:latest → lens:latest, container_name: nav → lens.

Existing deployments need to drop the old `nav` orphan container
and the old `navsrv:latest` image after pulling — `docker compose
down --remove-orphans && docker rmi navsrv:latest` once.
Strip personalized strings from defaults that ship to a fresh
install:

- server/bootstrap.json + scripts/bootstrap-source.ts:
  siteName "Pico 的小站导航" → "Lens"; siteCopyright
  "Copyright © 2026 Pico..." → "" (footer renders neutral when
  empty).

- web/src/lib/i18n/{en,zh}.json:
  admin.site.field.title.placeholder "Pico Nav"/"Pico 导航" →
  "Lens".

- package.json + package-lock.json (root e2e):
  "navigation_website-e2e" → "lens-e2e".

These are the seed/default code paths only. Existing instances
keep whatever the user has saved in their SQLite — no automatic
overwrite of user data; admins change site title via the
/admin → Site settings UI as usual.
User-facing docs:
- README.md: title (already Lens) + every navsrv reference in
  build commands, docker run/exec snippets, container_name,
  reverse-proxy notes → lens. Tagline now mentions the multi-site
  link feature explicitly so the unique selling point is in the
  first paragraph.
- server/README.md: # navsrv heading + binary path + CLI usage
  example all → lens.

Historical superpowers/{specs,plans}/*.md:
- 2026-05-19-rust-navigation-platform-design.md: drop the
  "Owner: Pico" metadata line.
- 2026-05-19-plan-1-rust-backend.md: every example Cargo.toml /
  use-import / git commit subject mentioning navsrv → lens.
- 2026-05-20-plan-1.5-svelte5-upgrade.md: example web
  package.json name "navigation-website" → "lens".
- 2026-05-20-plan-5-docker-e2e.md: Pico bootstrap payload swapped
  for the new neutral defaults; navsrv → lens throughout the
  sample Dockerfile / smoke-test commands / sample compose.

web/package.json: name "navigation-website" → "lens" (untouched
in the previous commits — included here with the rest of the
rebrand for grouping).

Repository directory name `navigation_website/` itself is left
unchanged in path references (e.g. spec line about the worktree
location); renaming the on-disk dir + GitHub repo is a separate
manual step.
- nginx static root /usr/local/webserver/lens/
- localStorage prefix navsite.* → lens.* (theme, locale, uiPrefs)
- spec doc references to old project name

Existing user prefs reset to defaults on next visit; uiPrefs already
gates on schema version so no breakage.
release.yml:
- Build web (pnpm in web/) and server (cargo in server/, SQLX_OFFLINE)
- Package lens-<ver>-linux-x86_64.tar.gz/.zip with binary + static + README
- New docker job pushes multi-tag image to ghcr.io/<owner>/<repo>

ci.yml (new):
- web: pnpm lint + check + test:unit + build, upload build artifact
- server: cargo fmt --check, clippy -D warnings, test
- Concurrency group cancels stale runs

Old release.yml ran pnpm build at repo root, which no longer produces
anything (root package.json is the e2e harness).
Auto-formatted on the worktree to match what `cargo fmt` and
`prettier --write` produce in CI runners.

No semantic changes — pure whitespace.
- vite.config.ts uses node:fs / node:path; svelte-check (now wired
  into ci.yml) needs @types/node resolved
- uiPrefs.ts: remove unused `get` import flagged by eslint
clippy::useless-conversion fires on .chain(others.into_iter()) since
chain takes IntoIterator directly. Local rustc 1.91 didn't catch it;
CI runs clippy 1.95.
After dropping .into_iter() the chain became short enough that
rustfmt 1.95 prefers one line; rustfmt 1.91 leaves it multi-line.
Match CI.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant