feat: upgrade architect - #10
Merged
Merged
Conversation
- 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
force-pushed
the
feat/rust-platform
branch
from
May 20, 2026 11:36
fe1b665 to
4614d73
Compare
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
force-pushed
the
feat/rust-platform
branch
2 times, most recently
from
May 20, 2026 11:49
9960709 to
3f282c7
Compare
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.
…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.
Primary fix — the home pager's wheel handler was hostile to macOS trackpad.
A two-finger horizontal flick injects 1–2 s of inertial wheel deltas at
~16 ms cadence; the previous 80 ms idle timer never fired during that
stream, so all of the inertia accumulated into offsetX and the
end-of-stream settle could only commit ±1 page — the rest had to be
animated back, which the user perceived as "slid past, snapped back".
Two changes in web/src/routes/+page.svelte:
1. Commit-as-you-go in onWheel. Once dxFromAnchor crosses w*0.5,
currentPage.setPage(±1) immediately and re-anchor wheelStartOffset
to the current offsetX (not to the new page's edge — that would let
tail-of-inertia jitter trip a reverse turn). A long flick now
advances N pages cleanly; the settle sees a near-zero residual.
2. Rubber-band formula. The previous expression was an amplifier, not
a damper, in the over=0..300 px range (e.g. over=50 produced
displacement=83). Replaced with the standard Apple curve
displacement = w * over / (over + w / RESISTANCE)
which is bounded by both `over` and `w` and asymptotes at one page
beyond max. Verified: over=50→27, over=500→268, both monotone.
Mouse wheel path is unchanged — discrete deltas still hit the 80 ms
idle and the 0.12 w settle threshold turns one page per click.
Drive-by:
- README.md: lead paragraph mentions the Launchpad-style pager and
jiggle drag-and-drop; Architecture / Frontend section explains why
the pager owns horizontal scroll itself; Roadmap moves the four
shipped items (pager, jiggle drag, folder expand, admin layout)
from "future" to "shipped".
- web/src/routes/+layout.svelte: drop the Footer (the home pager owns
its own bottom spacing via PageDots, admin pages render their own
chrome); shrink padding-bottom; document why we MUST NOT clip
overflow-x on <main> (kills the page-turn slide via 100vw fullbleed)
and clip on :global(body) instead.
- web/src/lib/components/Nav/EmptyState.svelte: introduce variant
system (search / offline / empty), inline glyphs per variant, optional
actions snippet, role=status + aria-live=polite for screen readers.
Verified: pnpm exec svelte-check → 0 errors / 0 warnings / 389 files,
pnpm exec eslint clean. Container rebuilt (docker compose build
--no-cache && up -d), :8080 returns http=200.
CI Web (lint + typecheck + unit + build) failed on prettier --check — the multi-line if condition in settleGesture is shorter than the print width and prettier wants it on one line. Pure reformat, no behavior change. Predates this branch's pager rewrite; just never tripped CI because pnpm lint had not been run against +page.svelte recently. Verified locally: pnpm lint → All matched files use Prettier code style! pnpm check → 0 errors / 0 warnings / 389 files pnpm test:unit → 39 / 39 tests passed pnpm build → built in 1.62s, adapter-static wrote site to build/
…resh Visual + i18n + dark mode pass over the launchpad and admin shell. i18n - Add 22 missing keys (en/zh in lockstep, 111 each) covering ItemEditDialog, IconSourcePicker, Header/AuthControls, ui/Chip, ui/Toast, admin sidebar aria-label. - Tighten admin.sites.hint copy: drop "shanghai/beijing" example that referred to the now-internal site value field. Editor / admin field-group pattern - Wrap IconSourcePicker (and Links per site) in a labelled field-group with semibold title + tinted body so complex composite fields read as one unit instead of merging into the surrounding flat label list. - Hide IconSourcePicker's inner "Icon source" label when the caller already provides a heading (new hideKindLabel prop). - AdminSitesTab: name+badge cell now has min-width so link-count column lines up across rows; create/edit row buttons centre on Input control mid-line; create vs edit forms are mutually exclusive (only one open at a time, sibling row actions disable). - AdminSitesTab "+ Add link" hides when saturated (was disabled ghost button that just looked broken). Globals - Promote select chevron + spacing to global app.scss base style so every native <select> in the app shares one look and component scopes don't have to duplicate. - Refresh dark palette: cooler neutrals, indigo-300 accent, glass card surface (translucent white + 1px inset highlight + backdrop blur), radial indigo+near-black gradient instead of flat purple.
Drag + jiggle behaviour fixes uncovered by hands-on testing. Per-page drag zones - Each page's <div class="grid"> now uses data-zone="root:p<idx>" so dragGrid treats pages as independent layout buckets. Previous single "root" zone made cross-page reorder shift the destination page's cards back to source-page off-screen positions, looking like no shift at all. - isRootZone() utility recognises both "root" and "root:p*" so merge, bucket-for, parent-for, and folder-out-to-root paths all keep working unchanged. - handleDrop skips redundant oldEntries renumber when source/target are different paged zones but share parent (root). - dragGrid: target.zone === 'root' checks loosened to startsWith so merge halo + autoFolder still arm on per-page roots. - Cross-zone shift on a saturated page wraps the overflow card to the next page's slot 0 (real off-screen rect) instead of phantom 4th-row extrapolation that left the card stuck under viewport bottom-left. Merge UX timings + visuals - Lower MERGE_OVERLAP_FRACTION 0.85 → 0.55 (cards no longer have to cover their target dead-centre to arm). - Tighten MERGE_ARM_MS 200 → 120 and MERGE_READY_MS 600 → 380 so feedback lands within one perceptual tick. - Bump MERGE_CANCEL_MOVE_PX 8 → 14 so a steady but jittery hold stops restarting the arm timer. - Shorten SHIFT_DURATION_MS 220 → 160 (Card transition matches); reorder shift was visibly trailing the cursor. - Edge-pan dwell 600ms → 350ms, interval 800ms → 600ms. - merge-armed halo bumped from 2px ring + scale 1.02 to 3px ring + bloom + scale 1.04 — the armed phase used to be invisible. Exit gestures - Global window dblclick handler in +layout.svelte exits jiggle from any empty area (header, gradient, grid) instead of only from the grid host. Skips interactive descendants via closest() guard. - Drop the now-redundant ondblclick on JiggleHost. LoginDialog - On 429 rate-limit catch, refresh sessionStore before deciding the login failed — server may have set the cookie before throttling the response. Closes the dialog if we're in fact authed.
… enum parse - repo/sqlx_impl.rs: parse_icon_kind / parse_card_kind now reject unknown DB values instead of silently falling back to Asset / Item. Restores the enum invariant; bad rows now surface as 500 with a tracing::error instead of corrupting the API contract. - routes/icons.rs, services/favicon.rs, routes/config.rs: switch in-handler std::fs::* to tokio::fs::* so blocking IO no longer parks a tokio worker. Bootstrap stays sync (one-shot at startup). - routes/auth.rs + main.rs: replace GlobalKeyExtractor with SmartIpKeyExtractor on the /api/auth/login governor layer, and serve with into_make_service_with_connect_info::<SocketAddr>() so peer-IP fallback works behind a direct connection. A single attacker can no longer lock every legitimate operator out. Reverse-proxy deployments pick up X-Forwarded-For / X-Real-IP / Forwarded automatically. - repo/config.rs: new pub mod keys with constants for every config k/v key (admin_password_hash, site_name, site_icp_*, default_theme, ...). All call sites in services/, routes/, cli.rs reference the constants so typos become compile errors and grep finds every reader/writer. - tests: api_auth / api_cards / api_config_password / api_icons now build the app via into_make_service_with_connect_info::<SocketAddr>() to give SmartIpKeyExtractor a peer IP source. cargo fmt --check clean, clippy --all-targets -D warnings clean, cargo test 48 passed / 0 failed.
Convert every runtime query in server/src/repo/sqlx_impl.rs and server/src/services/legacy_migrate.rs from the untyped sqlx::query / sqlx::query_as builder API to the typed sqlx::query! / query_scalar! macros. Column names, SQL syntax, and Rust binding types are now verified at compile time against the schema cached in server/.sqlx/. Why now: the file's header used to disclaim "untyped queries cost a bit of compile-time safety but stay stable across migrations" — this was a real trade-off when the schema was churning weekly, but Plan 6 has landed and the cards / card_links shape is stable. Past changes that touched column names (kind, parent_id, sort_order) were caught by tests only at runtime; they will now fail to compile until .sqlx/ is regenerated, which is the correct and louder signal. Mechanics: - sqlx_impl.rs: 858-line repo rewritten in place. All SELECTs use query! with explicit `as "col!: T"` annotations (sqlx-sqlite needs these because SQLite has no native NOT NULL information after a JOIN and AS-aliases the macro can read). list_sites_inner / list_cards_inner / fetch_card now produce typed row structs and map into dto::Site / dto::Card via the parse_card_kind / parse_icon_kind helpers added in the previous refactor — single point of truth for the textual enum decoding. - legacy_migrate.rs: same treatment for groups / items / item_links readers. Replaced the eight-tuple LegacyItem alias with a named struct so the field names are self-documenting. The DDL inside the migration (DROP TABLE item_links / items / groups) keeps the unchecked sqlx::query form, with a comment explaining why: these tables must exist when prepare runs against an unmigrated DB so the reader macros can resolve their columns. - .sqlx/: regenerated via `cargo sqlx prepare` against a freshly migrated SQLite. 31 stale entries dropped, replaced with the new typed-macro fingerprints. Net change is +29 fewer query files because the typed forms reuse fewer cache entries when the same parameterised SQL is invoked from different sites. Validation: - DATABASE_URL=<live db> cargo build --tests --quiet → clean - SQLX_OFFLINE=true cargo build --tests --quiet → clean (CI path) - SQLX_OFFLINE=true cargo clippy --all-targets -- -D warnings → clean - cargo test → 48 passed / 0 failed This closes the action item from the architecture review: "sqlx_impl.rs uses untyped Row::get throughout; column-name typo only fails at runtime → fix by switching to query! / query_as!."
Two architecture-review action items in one drop. Both are user-facing
hardening — wrong inputs are now rejected at the HTTP layer with a 422,
and intranet operators can swap the favicon source without touching code.
Payload validation (validator crate, was Cargo dep without callers):
- dto.rs: derive `Validate` on every write payload — CardPayload,
CardPatch, AutoFolderPayload, SitePayload, SitePatch. Fields get
length budgets (NAME_MAX=200, SLUG_MAX=100, SITE_VALUE_MAX=64,
ICON_VALUE_MAX=2048, DESCRIPTION_MAX=2000) and shape rules:
* slug ::= [a-z0-9][a-z0-9-]*
* site value ::= [A-Za-z][A-Za-z0-9-]* (matches existing seeds
shangHai / beiJing without forcing a data migration)
* links ::= map<site_value, non-empty url> capped at 64 entries
- error.rs: small `validate(&payload)` helper that turns a
validator::ValidationErrors into AppError::Validation, so handlers
stay one-liner: `validate(&body)?;`
- routes/cards.rs + routes/sites.rs: call `validate(&body)?` on every
create / patch / auto-folder before handing off to the repo. The
repo's own cross-field rules (folder requires slug, item requires
icon, etc.) still run after.
- dto.rs unit tests: ten new cases lock in the slug / site_value / name
/ link-count rules and prove the seed-data shape is accepted.
- tests/api_cards.rs: two new integration tests assert HTTP 422 for
empty `name` and uppercase slug, end-to-end through the validation +
AppError::IntoResponse path.
Favicon provider configurable + is_safe_host hardened:
- services/favicon.rs: extract `DEFAULT_PROVIDER_URL` constant with a
`{host}` placeholder. `FaviconService::new` takes the template
string; runtime URL is `template.replace("{host}", host)`. Operators
on intranets where Google s2 is unreachable can now point at e.g.
https://icons.duckduckgo.com/ip3/{host}.ico via env.
- is_safe_host: existing allow-list `[A-Za-z0-9.-]` was too permissive.
Now also rejects bare IPs (host.parse::<IpAddr>().is_ok()), single-
label hosts (no dot), and leading/trailing punctuation. Six new
unit tests — happy path, overlong, junk chars, single label,
punctuation, IP literals.
- config.rs: new `favicon_provider_url` field with a default function
pointing at DEFAULT_PROVIDER_URL. Existing settings test extended.
- state.rs: AppState gains `with_favicon_provider`; `new` is now a
shorthand that uses the default provider, so all existing tests and
test fixtures keep working unchanged.
- main.rs: production wires `settings.favicon_provider_url` through
the new constructor.
- .env.example: documents `FAVICON_PROVIDER_URL` with the DuckDuckGo
alternative.
Validation:
- cargo fmt --check clean
- SQLX_OFFLINE=true cargo clippy --all-targets -- -D warnings clean
- SQLX_OFFLINE=true cargo test → 57 passed / 0 failed
(was 48; +7 favicon unit tests, +10 dto validation unit tests,
+2 cards 422 integration tests)
Two small hardening passes left over from the architecture review.
argon2id with backward-compatible verify (auth/password.rs):
- Cargo.toml: add argon2 = "0.5". bcrypt stays in the dep tree because
existing deployments have bcrypt-shaped admin_password_hash entries.
- password::hash: now produces argon2id PHC strings using the crate's
default parameters (m=19 MiB, t=2, p=1 — within OWASP's 2024
Argon2id minimum profile).
- password::verify: dispatches by PHC prefix:
* "$argon2" → argon2id verify
* "$2a$" / "$2b$" / "$2y$" → bcrypt verify (legacy)
* anything else → Ok(false) with a tracing::warn
Means a deployment upgraded from a bcrypt-only build keeps logging
the operator in; the next /api/config/password POST or
`lens reset-password` writes a fresh argon2id hash, completing the
lazy migration. No data migration script needed.
- Tests: roundtrip, empty rejection, legacy bcrypt acceptance, and
unknown-format rejection (4 cases, all pass).
Direct From for tower_sessions::session::Error (error.rs + routes/auth.rs):
- error.rs: new `AppError::Session(#[from] tower_sessions::session::Error)`
variant. The session error type already implements `thiserror::Error`,
so #[from] just works.
- routes/auth.rs: drop the four-line .map_err(|e|
AppError::Other(anyhow::anyhow!(e))) wrappers in login / logout / me
handlers. Each call to session.insert / .flush / .get becomes a single
`?`. Type information is preserved through the error chain (vs the
old anyhow::Error -> AppError::Other -> 500-with-no-context path).
Net -7 lines, identical behaviour.
Validation:
- cargo fmt --check clean
- SQLX_OFFLINE=true cargo clippy --all-targets -- -D warnings clean
- SQLX_OFFLINE=true cargo test → 59 passed / 0 failed
(was 57; +2 from password test additions)
…m 0.8 ecosystem upgrade, legacy migrate removal, reorder batched
Bundle of five interlocking modernization steps. They land together
because the Cargo.lock churn from the axum upgrade dominates the diff;
splitting them into discrete commits would require five round-trips of
Cargo.toml + Cargo.lock + .sqlx regeneration with no real bisection
benefit (every step ends green, the tests prove that). Each section
below stands alone in motivation and could be cherry-picked back if
something regresses.
1. validator 0.16 → 0.20 (Cargo.toml + dto.rs)
The 0.18 release switched the derive syntax: `length(max = "CONST")`
to `length(max = CONST)` (bare ident), and `custom = "fn_name"` to
`custom(function = fn_path)`. Updated every payload struct in dto.rs;
the runtime behaviour is unchanged. Dropped the two `_opt` adaptor
shims that 0.16's stricter type checks needed — 0.20 is happy passing
`fn(&str)` to an `Option<String>` field directly.
2. figment Env::prefixed("LENS_") with bare-name back-compat (config.rs)
Settings::load now layers `Env::raw()` + `Env::prefixed("LENS_")`
last-merge-wins. Existing deployments that set bare `PORT` /
`DATA_DIR` / `BOOTSTRAP_ADMIN_PASSWORD` (Dockerfile, docker-compose,
scripts/, tests/cli.rs) keep working unchanged. New deployments —
particularly multi-tenant containers where `PORT` collides with
sidecars — can use `LENS_PORT` etc. and the prefixed form wins.
Two new tests cover the precedence (`LENS_` overrides bare) and the
back-compat (bare alone still works); both protected by an env-mutex
to defuse `cargo test`'s parallel std::env races.
3. Removed legacy_migrate.rs + migration 0005 (host-language migrate)
Plan 6's polymorphic `cards` table has been the source of truth for
months; the host-language migrate-on-boot from groups+items+item_links
into cards+card_links has fired on every existing v0.1+ instance and
is now redundant code that compiled & cached an entire row of typed
queries against tables that should not exist any more.
The replacement is migration `0005_drop_legacy_tables.sql` with three
`DROP TABLE IF EXISTS` statements — a no-op on every already-migrated
deployment, a true cleanup on any DB that somehow still has the
legacy tables (manual restore from old backup). Matched test in
db.rs::migrate_creates_tables now asserts both `cards` exists and
the legacy three are gone.
4. reorder_cards: N×UPDATE → single CASE WHEN per bucket (sqlx_impl.rs)
The previous code did one UPDATE per row in step 2 of the reorder
flow (stage every row to a fresh negative slot, then write contiguous
0..N). For a 50-card page that's 100+ round-trips. Replaced both
inner loops with a `sqlx::QueryBuilder` that emits one
`UPDATE cards SET sort_order = CASE id WHEN ? THEN ? ... END
WHERE id IN (...)` per bucket, plus a second batch for the apply.
Two batches per bucket (still required because SQLite enforces
UNIQUE row-by-row inside an UPDATE, so the negative-stage parking
must commit before the contiguous numbering goes in). The existing
`reorder_validates_contiguous_permutation` integration test exercises
the swap-(0,1) case that historically tripped the old code; it
passes against the new path.
5. axum 0.7 → 0.8, tower 0.4→0.5, tower-http 0.5→0.6,
tower-sessions 0.10→0.14, tower-sessions-sqlx-store 0.10→0.15,
tower_governor 0.3→0.8, sqlx 0.7→0.8, axum-test 15→20
Mostly mechanical:
- Path syntax: `/cards/:id` → `/cards/{id}` (cards.rs / sites.rs).
axum 0.8 panics on the old form; this is a hard rename.
- `axum::async_trait` removed from axum 0.8: native async-fn-in-trait
on stable. Dropped `#[async_trait]` from `RequireAuth`'s
`FromRequestParts` impl in auth/middleware.rs.
- `tower_governor::GovernorLayer` is no longer a struct-literal-able
type in 0.8; fields are private. Now constructed via
`GovernorLayer::new(Arc::new(config))`. The `SmartIpKeyExtractor`
setup itself is unchanged.
- axum-test 20: `TestServer::new(app)` returns a bare TestServer
instead of `Result`, and `do_save_cookies()` was renamed to
`save_cookies()`. Mechanical sed across tests/.
- tower-sessions has a documented version-skew between core 0.14 (used
by the sqlx-store 0.15) and core 0.15 (in tower-sessions 0.15). To
keep them aligned, this commit pins tower-sessions to 0.14 even
though 0.15 is on crates.io. Bump together when the sqlx-store
upstream rebases on 0.15.
- sqlx 0.8 changed its prepare cache file format slightly; .sqlx/
is fully regenerated in this commit.
Validation:
- cargo fmt --check clean
- SQLX_OFFLINE=true cargo clippy --all-targets -- -D warnings clean
- SQLX_OFFLINE=true cargo test → 60 passed / 0 failed (was 61 due
to the deleted legacy_migrate tests; net +1 from db.rs assertion
expansion)
- DATABASE_URL=<live db> cargo build clean (live macro path)
The 0001-0005 migration chain (init → drop_tags → create_cards → drop_i18n_cols → drop_legacy_tables) documented a schema design evolution that no external database has replayed — this project has not shipped. Collapse it into a single 0001_init.sql representing the final schema, so fresh deployments apply one clean migration rather than replaying design history. Existing local dev databases can be brought in line by rewriting `_sqlx_migrations` in place (5 rows → 1 row with the new SHA-384 checksum); schema itself is unchanged so no data touch is needed. Also dedup the .sqlx/ offline query cache: two callers of the `INSERT INTO config ... ON CONFLICT` upsert (ConfigRepo::upsert and upsert_many) inlined the SQL with different indentation, so the byte-hashing sqlx cache treated them as two distinct queries and persisted two JSON files for one semantic statement. Align the whitespace so both callers hash to a single cache entry; the sqlx::query! macro requires string literals so a factored-out const would not compile. Net: migrations 5 → 1, .sqlx JSONs 40 → 39.
bitxwave
added a commit
that referenced
this pull request
Sep 2, 2026
* docs: add design spec + Plan 1 (Rust backend implementation)
- 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.
* feat: Rust backend (Plan 1) — Axum + SQLite + auth + CRUD + CLI
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
* chore(web): upgrade Svelte 3 → Svelte 5 + Kit 2 + Vite 5 + ESLint 9 (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
* feat(web): foundation — types + i18n + design system + UI primitives (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
* feat(web): read-only browse — stores + Header + NavGrid + Footer (Plan 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
* feat(web): edit mode MVP — login + item CRUD + change password (Plan 4)
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.
* feat(deploy): Docker + Playwright e2e + README (Plan 5)
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.
* fix(deploy): make image actually build/run + fix theme palette clash
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.
* fix(web): UX feedback round 1 — gradient + glass header + center search + 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
* feat: layout mode (flat/grouped) configurable in Settings + drag-drop 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
* feat(web): redesign UI to master visual + Launchpad folder mode
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.
* chore(deploy): drop Caddy front-proxy, make PORT configurable end-to-end
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.
* fix: persist tag_slugs by upserting tag rows + clearer Grouped/Flat mockups
- 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.
* fix(web): align Grouped mockup with actual GroupHeader (chevron, not 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.
* fix(web): redraw Grouped mockup as Launchpad folder grid
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.
* feat(admin): consolidate config under /admin route + reject deletes that 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.
* feat: remove the tag system entirely
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.
* feat(admin): drag-to-reorder Groups and Sites
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.
* docs: add Launchpad unification design spec
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.
* feat(launchpad): migrate to cards model + jiggle UX + link-per-site
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.
* feat(launchpad): horizontal pager with dot indicator (P1)
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.
* feat(launchpad): cross-page drag via edge-pan dwell (P2)
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.
* feat(launchpad): site switcher as horizontal pill row (P3)
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.
* feat(launchpad): jiggle-mode click on item opens edit dialog (P4)
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.
* feat(launchpad): empty-grid placeholder card for first-time add (P5)
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.)
* feat(launchpad): jiggle phase stagger + hover lift (P6)
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.
* chore(launchpad): P7 — Tab cycle a11y + scope review
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.
* fix(launchpad): site switch back to dropdown + fix folder thumb overflow (P8)
Two follow-ups from in-house testing.
1. Site switch back to dropdown.
P3 introduced SitePills (horizontal pill row) in place of the
header SiteSelect dropdown. The user prefers the original dropdown
form, kept in the header right corner, so this commit reverts the
visual side of P3:
- Header renders <SiteSelect /> again (no path-based gating)
- delete SitePills.svelte (dead code)
- drop the .pills override and home.site.aria i18n key
The link-per-site data model (P3 was layered on top of) is
untouched — switching site still resolves card.links[siteValue].
2. Folder thumbnail overflow fix.
The 2x2 mini-grid inside a folder card (Card.svelte .thumbs/.thumb)
was letting individual favicon <img> elements push the cell past
its 1fr share, because grid items default to a min-content auto
floor and an <img>'s min-content is its natural pixel size. With
a high-res favicon the thumb blew out the 120x120 folder. Two
one-line CSS fixes:
- .thumbs > * { min-width: 0; min-height: 0 } — disable the
min-content auto-expansion so each cell is exactly 1fr
- .thumb { display: block; max-width: 100%; max-height: 100% } —
drop the inline baseline that was pushing rows down, and clamp
the natural image size as a belt-and-braces
The visual surround (white-70% bg, 8px radius, 3px padding) is
kept as before — only the size is now correctly contained.
Validation: pnpm lint passes for changed files. Docker rebuild
verified the home grid still renders with all folder thumbnails fitting
inside the 120x120 squircle.
* fix(launchpad): soften card drop-shadow, drop the dark halo (P9)
User reported a dark ring around every card on the home grid. The
light-theme --sh-card was 0 0 22px rgba(35,25,60,0.35) — a center-
spread shadow with zero offset, large blur, and 35% alpha. On the
warm-toned launchpad gradient that reads as a dim violet halo
hugging each tile rather than a drop shadow.
Switch to a directional drop shadow with low opacity:
--sh-card: 0 4px 16px rgba(35, 25, 60, 0.18);
--sh-card-hover: 0 8px 22px rgba(35, 25, 60, 0.26);
Same brand-violet tint, but offset down (so it reads as light coming
from above) and dimmer. Hover state is dimensionally taller, matching
the existing translateY(-2px) lift from P6.
Dark theme tokens unchanged — those are already directional drops
that work on the dark backdrop.
Verified with a docker rebuild + Playwright capture: the halo is
gone, cards keep clear separation from the gradient.
* fix(launchpad): folder title floats above the panel (P10)
Match macOS LaunchPad: when a folder opens, its name is rendered as
a floating label centered above the panel rather than as a left-
aligned header inside the card. The close × moves to the inner
top-right corner of the panel itself.
Implementation:
- new .wrap container holds <h3 class="title"> + <section class="expand">
in a vertical flex column (title on top, panel below, gap sp-3)
- the entry animation moves to .wrap so title + panel scale together
- .expand becomes position:relative so .close can absolute-pin to its
own inner top-right
- title uses card-label color + sh-card-label text shadow for contrast
on the gradient backdrop, fs-lg + semibold weight + slight letter-
spacing to read as a label, not body text
* fix(launchpad): drop the explicit close × from folder panel
Backdrop click and Esc already close the folder panel, so the inner
× button was redundant and competed with the floating title for
attention. Remove it for a cleaner macOS-style look. Also drop the
now-unused t import.
* fix(launchpad): more breathing room between folder title and panel
Bump .wrap gap from sp-3 (12px) to sp-5 (24px) so the title floats
visibly above the panel instead of crowding it.
* fix(launchpad): folder title editable in jiggle, drop the pencil affordance
Two related cleanups in folder-name editing:
1. The floating folder title in InlineFolderExpand was a static <h3>
with no rename path. In jiggle + authed it now renders as a
button; clicking turns into an inline <input> (Enter saves, Esc
cancels, blur saves), reusing the patchCard call + applyCardPatch
optimistic update Card.svelte uses for the home-grid label.
2. The pencil-in-circle "edit" button on the top-right of every item
card during jiggle is now redundant — P4 already routes a
jiggle-mode body click to the edit dialog, and the right corner
was visually busy alongside the × delete on the left. Drop the
button, its onEditClick handler, and its .edit-btn CSS.
Validation: pnpm lint passes for changed files. Docker rebuild
verified inline rename in the folder panel works (click name -> input
-> Enter -> name updates, panel stays open).
* fix(launchpad): rename racing two PATCHes against the SQLite lock
Symptom (server log):
ERROR ... PATCH /api/cards/4 ... database is locked
INFO ... PATCH /api/cards/4 ... 200
A 500 followed instantly by a 200 for the same card.
Root cause: the rename input wires both onkeydown Enter and onblur
to commitRename. Pressing Enter sets renaming=false; Svelte unmounts
the input in the same tick; the unmount fires the input's blur which
re-enters commitRename. Two concurrent patchCard() calls race for the
SQLite write lock — one gets it, the other returns "database is
locked".
Fix in both Card.svelte (home-grid folder label) and InlineFolderExpand
(folder panel title):
1. Add a `committing` flag. commitRename returns early if it's already
running, so the trailing onblur is a no-op while the first PATCH is
in flight.
2. cancelRename now resets renameValue back to the original name. So
if the user pressed Esc but had typed something, the trailing
onblur sees `next === folder.name` and skips the PATCH, preserving
the cancel intent.
* fix(launchpad): long-press release stays a pure mode switch
Two related polishes.
1. When the user long-presses a card to enter jiggle, the pointerup
that ends the long-press is *also* delivered as a click on the
source card. Within POST_LONGPRESS_GRACE_MS that click was being
routed to either onOpenFolder or onEdit — so a long-press on a
folder opened the folder, and a long-press on an item popped the
editor, on top of switching to jiggle mode. Surprising.
Inside the grace window, suppress every action: just enter jiggle,
leave the card untouched. The user must release and click again
to open / edit.
2. The delete affordance in jiggle is now a − (minus sign, U+2212)
instead of × — closer to the iOS / macOS Launchpad shorthand and
visually less aggressive.
* fix(launchpad): auto-close folder panel when dragging a child out
The folder expand panel + its backdrop-blur covers the root grid, so
once the user lifted a card out of the panel they had no way to aim
at a specific destination slot in root — the panel just stayed open
and the drop landed at the end of root via patchCard parentId=null.
Add a new dragGrid option onHoverZoneChange(prev, next) that fires
whenever the cursor's hover zone flips during an active drag. The
home page wires it to: if prev was "folder:<openFolderId>" and next
is "root", set openFolderId = null. The panel unmounts mid-drag, the
root grid is now fully visible, and dragGrid's elementsFromPoint
hit-test naturally starts seeing root cards on the very next tick —
so a precise reorder drop (Branch 2 of handleDrop) just works.
The drag clone lives on document.body and keeps following the
cursor; setPointerCapture is on window listeners, so unmounting the
source cell mid-drag is safe (browser auto-releases capture, no
pointercancel).
* fix(launchpad): spring-load only when drag started from root
Bug found in server logs:
reorder_cards entries: (id=20, parent=Some(3), sort=0), ...
The user lifted card 20 out of folder 26, the home panel auto-closed
on entering root (previous fix), but as the cursor crossed root on
its way to a target slot it lingered ~500ms over folder 3 — spring-
load fired, folder 3 popped open under the drag, and the release
landed *inside* folder 3 instead of in root.
Constrain spring-load to drags whose source.zone === 'root'. Once
the user has committed to "drag this child out of folder A", another
folder shouldn't be allowed to auto-grab it on the way past. Cross-
folder moves are still expressible: release into root, then drag
from root into folder B (root sources still spring-load normally).
The handleDrop branch for "drop directly on a folder card with merge
intent" (item-on-folder reparent) still works regardless of source —
spring-load was just the dwell-to-open behavior, not the on-release
behavior.
* fix(launchpad): dragGrid only attaches to the main card/folder button
Two reported regressions had the same root cause. The cell wraps
several buttons (the main .card / .folder, the delete −, and in
jiggle the rename label too). dragGrid was happy to start a drag
session on any pointerdown that landed inside the cell — including
on those inner buttons. The session's onPointerDown calls
preventDefault, which on some browsers suppresses the synthetic
click that follows on a short tap, so:
- Tapping a folder's name label in jiggle did nothing — startRename
never fired.
- The hover-zone close logic was correctly close-firing for normal
drags, but a quick tap-and-release on the inner button was
starting and finishing a tiny "drag session" that confused the
flow elsewhere.
Restrict cellFromEvent to events whose target is inside the .card
or .folder button (or the <img> within). pointerdown on the delete
button, the rename label, or any future inner control falls through
to its own click handler — no drag session, no preventDefault.
Also drop the temporary console.log added while diagnosing.
* fix(launchpad): folder name in home grid is no longer editable
Per user request: rename should only be reachable from inside the
opened folder panel (the floating title above the panel). The
home-grid folder label is now a plain <span> like an item label.
Drop the rename state, the start/commit/cancel handlers, the
patchCard import, and the .label.rename / .rename-input styles.
* fix(launchpad): auto-close folder panel reliably when dragging out
The previous condition only fired the auto-close when hoverZone
transitioned `folder:N` → `'root'`. That covered the case where the
cursor lands directly on a root card, but missed the much more common
case where the cursor is on the panel's backdrop blur with no root
card under it (panel covers the central area where most root cards
sit). In that state hoverZone goes to null and the close never fires —
panel stays up, blocking the destination.
Fix in two parts:
- InlineFolderExpand: hoist data-zone="folder:N" from the inner
grid up to the .wrap (the fixed positioned panel container), so
the entire visual panel rect — including the .expand padding ring
and the title row — registers as the open folder zone. Cards
inside still resolve their own zone via the closer .grid ancestor,
so cell zone identification is unchanged.
- +page.svelte onHoverZoneChange: relax the close condition from
`next === 'root'` to `next !== myZone`. Now any zone change away
from the open folder closes the panel — covers both the root-card
landing and the backdrop-blank case.
* fix(launchpad): don't spring-load when source is a folder
Spring-load opens a folder under the cursor on hover-with-merge-intent
during an active drag. The previous gate only required source.zone
=== 'root', which let folder-on-folder hovers also spring-load the
target. That's never useful: handleDrop already coerces every
folder-source merge into a reorder (folders can't be nested inside
other folders) or rejects it, so opening the target folder just hides
the destination slot from view mid-drag.
Add `source.kind === 'item'` to the gate. Item-on-folder still
spring-loads (the legitimate "drop inside" case); folder-on-anything
goes straight to before/after reorder.
* fix(launchpad): swallow trailing click after long-press, regardless of hold duration
f6f3ef3 used a 350 ms time window after `jiggleEnteredAt` to decide
whether a click was "the long-press release" (no-op) or a fresh
click-while-in-jiggle (open folder / pop edit dialog). That works
when the user releases shortly after entering jiggle, but anything
beyond 950 ms total hold falls outside the window — so a long
hold-then-release would enter jiggle AND immediately open the folder
or the item editor on release. Same bug for both kinds of card.
Replace the time-window heuristic with a flag in the longPress action
itself. When the long-press timer fires, set `consumeNextClick = true`
and install a one-shot capture-phase click listener on the action
node. The listener stopImmediatePropagation()s the next click so the
inner button's bubble-phase onclick never sees it. The flag is reset
on every fresh pointerdown, so a real tap after a drag-canceled
long-press still works.
Also drop the now-unused jiggleEnteredAt store and the time-window
constant in Card.svelte.
* refactor(launchpad): extend MergeCandidate with phase and add cellShifts store
Foundation for dwell-gated merge: store now distinguishes 'armed' vs
'ready' phases of merge intent, and a new cellShifts store carries
per-card translation deltas for reorder preview. No behavior change yet.
* feat(launchpad): pure computeShifts function with unit tests
Per-card translation map for reorder preview. Same-zone shifts cards
between source and drop indexes; cross-zone closes source's gap and
opens a target slot. Returns empty when merge intent is committed.
* test(launchpad): cover computeShifts cellAdvance extrapolation and guard negative sourceLogicalIdx
- Add explicit test for the cross-zone extrapolation path (last card
shifting past tgtBucket.length).
- Add early return when sourceLogicalIdx < 0 (defensive against
pre-lift session sentinel).
- Add a test for the new guard.
* feat(launchpad): layout cache builder and cursorOnLeftHalfOf helper
buildLayoutCache snapshots every [data-card-id] cell's logical index
and rect, grouped by [data-zone]. Used at drag lift to keep insertion
math stable independent of mid-drag transform shifts.
* docs(launchpad): document cellAdvanceByZone absence semantics
Per code review on 2026-05-26: callers (Task 5+) need to know that a
zone with <2 cells produces no entry in the map and they must handle
the missing case.
* feat(launchpad): dwell session state and merge-arm helpers
Adds layoutCache, sourceLogicalIdx, and dwell timers/state to
DragSession. Introduces startMergeArmForItem / setMergeImmediateForFolder
/ cancelMergeArm. Not wired into applyHover yet — Task 5 does that.
* refactor(launchpad): split clearMergeArmInternal to avoid mergeCandidate 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).
* feat(launchpad): wire layout cache, dwell, and shift preview in dragGrid
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.
* perf(launchpad): dedupe cellShifts writes and cache bucketsRecord
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).
* feat(launchpad): resolve drop intent based on merge-arm gate
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.
* feat(launchpad): card visual reorder shift and two-tier merge halo
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.
* refactor(launchpad): tighten Card.svelte CSS — pause jiggle on merge, 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.
* feat(launchpad): rebuild drag layout cache on spring-load and edge-pan
Layout-changing events mid-drag invalidate the cache; expose
rebuildLayoutCache() from the dragGrid action and call it from
+page.svelte after tick().
* fix(launchpad): correct drop index when src is before target + handle 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).
* fix(launchpad): three drop-UX issues — flicker, folder-source halo, merge 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.
* fix(launchpad): keep jiggle animation phase stable across drag-induced 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.
* fix(launchpad): defer cellShifts/data-dragging cleanup until drop API 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.
* fix(launchpad): make the "+" affordance shift along with displaced cards
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.
* fix(launchpad): hide "+" affordance during active drag instead of shifting 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.
* fix(launchpad): switch drag-active gating from :has() to store-backed 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.
* fix(launchpad): unblock folder-source drag — 5 UX fixes
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.
* fix(launchpad): anchor folder panel + guard JiggleHost click
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.
* fix(ui): drop backdrop dismissal + restore footer Cancel contrast
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.
* feat(admin): dedicated dashboard layout, i18n, password move
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.
* docs(launchpad): drag merge / reorder disambiguation spec + plan
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.
* refactor(server): rename crate navsrv → lens
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.
* refactor(deploy): rename Docker image, service, container to lens
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.
* chore: rebrand defaults — Lens project name, no personal info
Strip personalized strings from defaults that ship to a fresh
install:
- server/bootstr…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/):What's in (Done)
Backend (
server/)NavRepo,ConfigRepotraits +sqlximpls); offline.sqlx/metadata committed for reproducible buildsGET /api/navreturns fullNavBundle(camelCase, zod-mirrorable)tower-sessions(SQLite store), 30-day sliding expirytower-governor, 5 burst per 15 min)BOOTSTRAP_ADMIN_PASSWORDor auto-generated 24-char password written toINITIAL_PASSWORD.txt(auto-deleted on firstpassword change)
RequireAuth)POST /api/icons/upload, multipart, ≤1 MiB, ext-allow-list)GET /api/favicon?host=…)ServeDir+ SPA fallback (Rust serves the SPA +/api/*from one process)navsrv reset-password [--password=…]invalidates all sessionscargo clippy --all-targets -- -D warningscleanFrontend (
web/)$props/$state/$derived/$effect/$bindable)NavBundle; everyapiClientresponse zod-validated{{name}}interpolation)prefers-color-scheme)Button/IconButton/Input/Dialog/Toast(+ToastViewport+ store) /Menu/Chip/Switch/Card/
SkeletoneditModeStore/focus + TagFilterChips + SiteSelect + ThemeToggle + LocaleToggle + AuthControls)ChangePasswordDialog
pnpm check / build / lintcleanDeploy
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)tests/read.spec.ts+tests/edit.spec.ts, port 18080, chromium-only) +scripts/e2e.shharnessRepo layout
Test plan
Required (already verified in CI-equivalent local runs)
cd server && SQLX_OFFLINE=true cargo test— 40 tests passcd server && cargo clippy --all-targets -- -D warnings— cleancd server && cargo fmt --check— cleancd web && pnpm check— 0 errorscd web && pnpm test:unit— 31 tests pass across 7 filescd web && pnpm build— succeedscd web && pnpm lint— cleancargo run→/api/health200,/api/navreturns seeded bundle, login → cookie →/api/auth/mereturns{authenticated: true}pnpm devproxies/apito:8080, page renders without errorsDeferred (Docker daemon was offline during the implementation session — REVIEWERS PLEASE RUN)
bash scripts/docker-smoke.sh— builds image, runs container, hits all endpointsbash scripts/e2e.sh— full Playwright e2e against the Docker image (read + login + create item)docker compose up -d(withDOMAIN=localhost) — Caddy fronts the containerManual smoke (recommended)
docker run -d --name nav -p 8080:8080 -v ./data:/app/data -e BOOTSTRAP_ADMIN_PASSWORD=changeme navsrv:latesthttp://localhost:8080— see seeded 17 items across 4 groups+card → fill form → Save; click 🔑 →change password
INITIAL_PASSWORD.txtis auto-deleted fromdata/docker exec -it nav navsrv reset-password --password=newpw— invalidates sessionsArchitecture decisions worth calling out
tower-http::ServeDirwith SPA fallback +/api/*. No nginx, noseparate frontend container. Volume
/app/dataholds SQLite + uploads + INITIAL_PASSWORD.txt — container is stateless.z.infer<typeof Schema>. Backend serde structs arehand-maintained alongside (kept in sync at PR review time — small repo, low friction).
web/src/lib/components/ui/, all driven by CSS custom properties fromtokens.scss. Eachprimitive is one file,
<button>-based for a11y, focus-visible outlines, keyboard navigable.t()is a Sveltederivedstore,{{name}}interpolation. Nolibrary.
secure_cookies. Plainsidover HTTP (dev),__Host-sidover HTTPS (prod). RFC 6265bis–compliant.pnpm 10 + lockfile v9pinned viaweb/package.jsonpackageManagerso 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):
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:bootstrap.jsoncontaining 17 default items across 4 groups(network/media/nas/tools). Edit them via the UI after first login.
scripts/bootstrap-source.ts, runnode scripts/dump-bootstrap.mjsto regenerateserver/bootstrap.json, thenrebuild.
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.