feat: upgrade architect#10
Open
bitxwave wants to merge 77 commits into
Open
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.
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.
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.
Adds layoutCache, sourceLogicalIdx, and dwell timers/state to DragSession. Introduces startMergeArmForItem / setMergeImmediateForFolder / cancelMergeArm. Not wired into applyHover yet — Task 5 does that.
…ate null pulse setMergeImmediateForFolder previously called cancelMergeArm which set mergeCandidate to null before re-setting it to ready in the next synchronous statement. Subscribers (especially eager ones) saw a spurious null transition on every folder hover. Pull the timer-clearing logic into clearMergeArmInternal and call it from setMergeImmediateForFolder, leaving mergeCandidate alone so the store transitions directly from prior state to ready. Per code review on Task 4 (commit 03de5fd).
liftSource builds the per-session layout cache. applyHover drives the dwell state machine (item: 200/600 ms two-stage; folder: instant) and republishes cellShifts each tick. mergeCandidate now includes phase.
publishShifts runs per rAF tick and previously published a fresh Map to cellShifts every time, triggering reactivity in every Card subscriber even when shifts hadn't changed. Add shiftsEqual + setShifts helpers that skip writes when value-equal to the last published map. Also pre-build layoutCache.bucketsRecord once at lift instead of rebuilding the Record from byZone every tick. Per code review on Task 5 (commit 83a060e).
resolveFinalIntent: dropping on an item where the dwell never fired falls back to before/after by cursor side. Releasing in armed/ready state commits merge. Folder targets always merge.
Card subscribes to cellShifts and applies translate transform; merge halo splits into merge-armed (faint, 200ms) and merge-ready (full, 600ms). Source cell goes invisible while dragging so the gap is shown.
… scope data-dragging selector, export SHIFT_DURATION_MS Three follow-ups to commit d9d540a per code review: - Jiggle keyframes own `transform: rotate(...)` on the same inner element our merge-armed/ready rules target, so during drag the merge scale was effectively dead. Pause `animation-play-state` on those states so the static scale renders. - Mark `[data-dragging='true']` :global so Svelte's static analyzer doesn't flag the imperatively-set attribute as unused. Keeps `.cell` scoped to this component. - Export SHIFT_DURATION_MS so it's not flagged unused; +page.svelte can later wire it into the `--shift-duration` CSS var. - Reflow mergePhase $derived to a single line per Prettier.
Layout-changing events mid-drag invalidate the cache; expose rebuildLayoutCache() from the dragGrid action and call it from +page.svelte after tick().
… between-cards drops Two related bugs surfaced after Task 7 added the visible reorder shift: 1. Same-zone, src-before-target: handleDrop computed `insertAt = effectiveIntent === 'before' ? targetIdx : targetIdx + 1` directly off the target's idx in the pre-removal bucket. But reorderEntries operates on the post-removal list, where target's idx shifts up by 1 once source is filtered out. Result: dropping "before C" with source A actually placed A after C. publishShifts had the same off-by-one in its dropIdx formula. 2. Cursor in the gap between two cards (no DOM cell hit) silently no-op'd: handleDrop's branches all required `info.target`, and publishShifts fell back to `dropIdx = bucket.length` (append at end). Both wrong: the gap IS the natural reorder target. Fix: - SlotRect now carries `kind`, populated by buildLayoutCache. - publishShifts always picks the closest non-source slot from the cached layout and computes dropIdx via cursor side + post-removal adjustment. No longer relies on elementsFromPoint hitting a cell. - resolveFinalIntent synthesizes a target+intent from the closest slot when none was hit, so handleDrop has something to anchor on. - handleDrop applies the same post-removal correction to insertAt. Tests stay 39/39 (computeShifts math was already correct; the bugs were in the dropIdx callers).
…erge threshold 1. Flicker on drop. cellShifts cleared in finish() while reorderCards API was still in flight, so cards transitioned from shifted back to 0 over 220ms while handleDrop's refetch concurrently rerendered them in the new sort order — visible snap+wobble. Suppress the transform transition for two rAF frames around drop cleanup so the snap is instant; refetch + DOM reorder happens during the suppression window. Restored on the third frame. 2. Folder source no longer triggers merge halo. Dragging a folder over another folder or item is always reorder (folders can't nest). The dwell state machine in applyHover now gates both the item-arm and the folder-instant paths on `source.kind === 'item'`, so folder sources never set mergeCandidate. 3. Merge zone is now overlap-based, not cursor-position-based. The old "cursor in inner 50%" rule fired whenever the cursor brushed the middle of a card, even when the dragged card barely touched the target. Replaced with a real geometric check: the dragged card has to overlap the target by ≥ MERGE_OVERLAP_FRACTION (0.85) of the smaller card's area before classifyIntent returns 'merge'. Tunable constant; user asked for a much stricter threshold than before. classifyIntent signature change: now takes (target, dragged, cursorX) instead of (target, cursorX, cursorY). computeHover constructs the dragged rect from cursor + cloneOffset + cached source dimensions.
…d reorder Animation delay was assigned via :nth-child(3n+2)/:nth-child(3n) CSS selectors. When a drag commit reorders the grid, every card's nth-child position changes — and so does its animation-delay — which yanks the wobble to a new phase mid-stride. Visually this reads as a flicker on either the dropped card or a neighbor, regardless of the transform-transition suppression added in 9deb642. Move the delay onto a per-card CSS variable (--jiggle-delay) computed from card.id (a stable identifier) and exposed via style:--jiggle-delay on the cell. Each card now keeps its own continuous wobble phase across reorders.
… resolves
The flicker after a successful drop was a coherence problem, not an
animation problem. With the previous code:
1. finish() fires onDrop and synchronously clears cellShifts +
data-dragging.
2. Cards snap to their pre-drag logical positions (no transition,
transform = 0). Source becomes visible at its old slot.
3. ~200ms later, reorderCards completes, navDataStore refetches,
Svelte re-renders the each block with the new sort order.
4. Cards snap from old positions to new positions.
Steps 2 and 4 are TWO visible state changes in quick succession —
that's the flicker the user reported, and what the screen recording
shows on both the dropped card and a neighbor.
Make the visual cleanup conditional on the onDrop Promise:
- During the await window, data-dragging stays on (source hidden,
its slot acts as the visible gap), cellShifts stays in place
(neighbors stay in their shifted positions). The visual matches
the pre-drop preview, so the user sees no change yet.
- When handleDrop's promise resolves, navDataStore has been
refetched synchronously inside the same microtask. We then clear
visual state. Svelte's render flush sees both the new data AND
the cleared shifts in one pass, so the cards land directly at
their new positions with no intermediate frame.
DragGridOptions.onDrop is now `void | Promise<void>` to declare the
contract. Existing void consumers stay compatible — the cleanup runs
synchronously when no Promise is returned.
When a real card was shifted past the last card during a reorder preview, the "+ new item" affordance stayed put — the displaced card visually overlapped the affordance instead of pushing it forward. Fix: include the affordance container as a phantom slot in the layout cache. It is marked with `data-add-cell`; buildLayoutCache appends one slot per affordance to its zone with cardId = ADD_CELL_CARD_ID (-1) and a kind of 'item'. computeShifts treats it like any other slot, so a card landing at logicalIdx == affordance's slot causes the affordance to translate to the next CSS-grid slot. resolveDropIdx and resolveFinalIntent skip phantom slots — the affordance is not a valid drop target. The page reads cellShifts.get(ADD_CELL_CARD_ID) and applies the same translate transform as Card.svelte does for real cards, with a matching 220ms cubic-bezier transition so the motion is uniform.
…fting it Previous attempt (commit 7e2238e) treated the affordance as a phantom slot in the layout cache and let computeShifts translate it. That worked for in-row shifts but failed at row boundaries: cellAdvance extrapolates by one column horizontally, which pushes the affordance past the grid's last track in CSS auto-fill grids. With `justify-content: center` on .grid the result was an off-grid position clipped at the viewport's right edge — exactly the half-visible affordance the user reported. Drop the phantom-slot approach. The affordance is purely a UI shortcut (it isn't a drop target and the user can't click it mid-drag), so the simplest correct behavior is to hide it visually for the duration of the drag and let the natural CSS-grid auto-flow handle its position both during (invisible) and after (re-shown at the end of the new card list). `visibility: hidden` (not `display: none`) preserves the affordance's CSS-grid slot so the layout cache rect stays consistent with what the user lifted on. The CSS rule is gated on `.canvas:has([data-card-id][data-dragging='true'])` so it only applies during an active lift. Removes the now-unused ADD_CELL_CARD_ID export and the phantom-slot logic in buildLayoutCache, resolveDropIdx, and resolveFinalIntent.
… class InlineFolderExpand unmounts when the cursor leaves its zone (handled by onHoverZoneChange in +page.svelte). The unmount detaches the source cell carrying [data-dragging='true'] from the DOM, so the :has([data-card-id][data-dragging='true']) descendant selector loses its match — silently disengaging the .add-cell visibility-hidden, .pager scroll-snap-type:none, and .canvas touch-action:none rules mid-drag. Bind .canvas's drag-active class to \$dragSource (set on lift, cleared on drag end by dragGrid) so the gate is anchored on a stable store signal that outlives any descendant unmount. The four \$global :has(...) selectors collapse into the equivalent .canvas.drag-active local rules.
1. Spring-load drops the source.zone === 'root' restriction. Dragging a child card from one open folder to another folder card in root now opens the destination on dwell, same as root → folder. The pre-existing comment said "spring-load only when src came from root"; with the source's panel covering root via fixed-position z-index 80+, elementsFromPoint can't accidentally hit a root folder card behind it anyway, so the gate was over-restrictive. 2. Merge halo gates on target.zone === 'root' (in addition to the pre-existing kind / source.kind checks). Inside an open folder panel a "merge" cursor is ambiguous — no nested folders, every child already lives in a folder — and +page.svelte's drop handler coerces merge → before/after there. Mirroring that here stops the blue ring from lying about what release will do. 3. mergeCollapse's setShifts(new Map()) replaced with computeSourceGapClose(session). The source cell stays in its CSS-grid slot at opacity:0 (so layoutCache rects stay stable), and clearing every shift exposed that hidden slot as a 6×-wide visible gap between visible siblings. The new helper shifts every same-zone slot whose logicalIdx > source's by one position back, mirroring the close-gap arm of the cross-zone branch in computeShifts but without opening any insertion gap. 4. Source dimensions cached at lift (sourceLiftWidth/Height on the session) and used by computeHover instead of layoutCache.byCardId.get(source.id)?.rect. After spring-load triggers rebuildLayoutCache, the source's folder panel has already been unmounted by onHoverZoneChange so the rebuilt cache has no entry for source.id; the old fallback gave 0×0 draggedW/H, classifyIntent's overlap area never reached MERGE_OVERLAP_FRACTION, isMergeFolder/isMergeItem never tripped, and a release on a folder card silently reordered next to it (branch 2 in handleDrop) instead of reparenting (branch 1). 5. Cross-zone target extrapolation in computeShifts now reads CSS- grid colCount + rowGap to wrap to row N+1 col 0 when newIdx crosses a column boundary. Previously cellAdvance gave a single horizontal step, so dropping into a folder whose last visible row was already full pushed the trailing card off-grid right and the browser scrolled the container horizontally until release. buildLayoutCache resolves geometry per zone via getComputedStyle(gridEl).gridTemplateColumns (auto-fill resolves to an explicit "120px 120px ..." list) and stores it on LayoutCache.gridGeometryByZone. ComputeShifts gains an optional gridGeometryByZone input so existing single-row unit tests continue to exercise the legacy col-step path.
InlineFolderExpand previously centred .wrap with top:50% + translate(-50%,-50%); for sparse folders that left a large empty band above the title and made the panel float low on the viewport. Switch to top:120px + translateX(-50%) so few-card folders show up near the top, full folders still fill downward up to the calc(100vh - 120px - sp-7) max-height clamp. JiggleHost.host's background-click handler exited jiggle mode when e.target === e.currentTarget. The browser dispatches click on the *common ancestor* of mousedown- and mouseup-targets, so drag- selecting a card label and releasing between cards triggered the handler with target === currentTarget and exited jiggle mid- gesture. Track the pointerdown origin and only exit when the press also began on .host.
Two fixes in the shared Dialog component:
1. Remove the backdrop pointerdown/click listeners. Clicking the
dimmed area was dismissing dialogs mid-edit — both via accidental
pointer slips and via the synthetic-click-on-common-ancestor
effect when a user drag-selected text inside an input and mouseup
landed outside the dialog. Forms with multiple fields and
unsaved input were worst-affected. Esc and the consumer-rendered
footer Cancel button remain the only close paths.
2. Footer ghost button text was rendering invisible against the
dialog's glass surface because Header.svelte's
.header :global(.btn.intent-ghost) { color: var(--c-card-label) }
(white text for buttons over the public site's gradient) bleeds
into LoginDialog — the dialog is rendered as a DOM descendant of
<header> via AuthControls. Raise the Dialog footer ghost rule's
specificity to (0,5,0) by prefixing with .backdrop and then
explicitly setting color: var(--c-text). Beats the header
override regardless of cascade order; dark-mode counterpart
updated to match.
Admin pages now opt out of the public Launchpad chrome (Header, Footer, gradient backdrop) and render their own dashboard shell — a 56px topbar with brand + tab breadcrumb + Locale/Theme toggles + Logout, a 240px sidebar with Settings tabs, and a content card body. Only --c-bg / --c-surface / --c-border / --c-accent tokens used; no admin-specific colour palette introduced. Layout detachment via routes/+layout.svelte: when the URL starts with /admin, skip the public Header/main/Footer wrap entirely and render children directly. body.admin-route flag in app.scss flips #root's background from gradient to --c-bg. i18n: 41 admin.* keys added to zh.json and en.json. Every label, description, placeholder, toast, confirm, badge in the admin chrome and tab bodies now reads via \$t(). Sites tab cleanup: - Drop the user-facing Value (key) input. Sites carry an opaque identifier (referenced by card.links[siteValue]); exposing it as a writable input let users break referential integrity. Now generated client-side as s_<base36 ts><rand4> on create and never edited again. - Display row hides the slug; edit row only changes name + isDefault; the patch payload omits value to make the immutability explicit. - Save/Cancel align to the input box bottom (grid align-items: end + matching .def height) instead of floating at the row top. Account tab (new) hosts password change, replacing the icon- button + ChangePasswordDialog popup that lived in the public header. ChangePasswordDialog deleted; AuthControls trimmed to just Admin gear + Logout (or Login when unauthed). After a successful password change the page redirects home — server invalidates sessions on password update.
Adds the design contract and the task-by-task implementation plan that drove the recent Launchpad UX rework — dwell-gated merge, shift-to-make-room reorder preview, cross-zone shifts, and the follow-up fixes (drag-active class, folder-source spring-load, mergeCollapse source-gap close, wrap-aware extrapolation). Spec: behavior contract, hover-state machine, layout cache, drop intent resolution, manual + unit acceptance. Plan: 9 tasks with code-level diffs and per-task git-commit templates. Progress tracking via checkbox syntax.
Cargo.toml: package.name, default-run, [[bin]].name, [lib].name all
flipped to "lens". 14 test files + 1 src file have their `use
navsrv::` import paths updated. Three string literals also moved:
clap CLI program name (`#[command(name = "lens", version)]`),
tracing log line ("lens listening"), and the favicon HTTP user-
agent ("lens-favicon/0.1").
The seed test's bootstrap assertion now expects the new default
site name "Lens" — that change rides along here so cargo test stays
green within this single commit. Cargo.lock is regenerated by
cargo because the local package name changed.
Dockerfile: --bin navsrv → --bin lens (×2 across the cargo cache layer + real build), target/release/navsrv → target/release/lens, /usr/local/bin/navsrv → /usr/local/bin/lens, ENTRYPOINT updated. docker-compose.yml: service key `nav:` → `lens:`, image: navsrv:latest → lens:latest, container_name: nav → lens. Existing deployments need to drop the old `nav` orphan container and the old `navsrv:latest` image after pulling — `docker compose down --remove-orphans && docker rmi navsrv:latest` once.
Strip personalized strings from defaults that ship to a fresh
install:
- server/bootstrap.json + scripts/bootstrap-source.ts:
siteName "Pico 的小站导航" → "Lens"; siteCopyright
"Copyright © 2026 Pico..." → "" (footer renders neutral when
empty).
- web/src/lib/i18n/{en,zh}.json:
admin.site.field.title.placeholder "Pico Nav"/"Pico 导航" →
"Lens".
- package.json + package-lock.json (root e2e):
"navigation_website-e2e" → "lens-e2e".
These are the seed/default code paths only. Existing instances
keep whatever the user has saved in their SQLite — no automatic
overwrite of user data; admins change site title via the
/admin → Site settings UI as usual.
User-facing docs:
- README.md: title (already Lens) + every navsrv reference in
build commands, docker run/exec snippets, container_name,
reverse-proxy notes → lens. Tagline now mentions the multi-site
link feature explicitly so the unique selling point is in the
first paragraph.
- server/README.md: # navsrv heading + binary path + CLI usage
example all → lens.
Historical superpowers/{specs,plans}/*.md:
- 2026-05-19-rust-navigation-platform-design.md: drop the
"Owner: Pico" metadata line.
- 2026-05-19-plan-1-rust-backend.md: every example Cargo.toml /
use-import / git commit subject mentioning navsrv → lens.
- 2026-05-20-plan-1.5-svelte5-upgrade.md: example web
package.json name "navigation-website" → "lens".
- 2026-05-20-plan-5-docker-e2e.md: Pico bootstrap payload swapped
for the new neutral defaults; navsrv → lens throughout the
sample Dockerfile / smoke-test commands / sample compose.
web/package.json: name "navigation-website" → "lens" (untouched
in the previous commits — included here with the rest of the
rebrand for grouping).
Repository directory name `navigation_website/` itself is left
unchanged in path references (e.g. spec line about the worktree
location); renaming the on-disk dir + GitHub repo is a separate
manual step.
- nginx static root /usr/local/webserver/lens/ - localStorage prefix navsite.* → lens.* (theme, locale, uiPrefs) - spec doc references to old project name Existing user prefs reset to defaults on next visit; uiPrefs already gates on schema version so no breakage.
release.yml: - Build web (pnpm in web/) and server (cargo in server/, SQLX_OFFLINE) - Package lens-<ver>-linux-x86_64.tar.gz/.zip with binary + static + README - New docker job pushes multi-tag image to ghcr.io/<owner>/<repo> ci.yml (new): - web: pnpm lint + check + test:unit + build, upload build artifact - server: cargo fmt --check, clippy -D warnings, test - Concurrency group cancels stale runs Old release.yml ran pnpm build at repo root, which no longer produces anything (root package.json is the e2e harness).
Auto-formatted on the worktree to match what `cargo fmt` and `prettier --write` produce in CI runners. No semantic changes — pure whitespace.
- vite.config.ts uses node:fs / node:path; svelte-check (now wired into ci.yml) needs @types/node resolved - uiPrefs.ts: remove unused `get` import flagged by eslint
clippy::useless-conversion fires on .chain(others.into_iter()) since chain takes IntoIterator directly. Local rustc 1.91 didn't catch it; CI runs clippy 1.95.
After dropping .into_iter() the chain became short enough that rustfmt 1.95 prefers one line; rustfmt 1.91 leaves it multi-line. Match CI.
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.