feat: upgrade architect#10
Open
bitxwave wants to merge 86 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.
…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.
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)
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.