···7272- If a front-end slice needs a renderable primitive/demo surface, prefer a non-root route such as `/foundation`. Keep `/` as the product shell so implementation scaffolding does not become the primary product surface.
7373- Use flex column for simple vertical page shells. Reserve CSS grid for actual two-dimensional layouts; grid containers combined with `min-height: 100vh` can create surprising stretched rows and large visual gaps.
7474- During visual/layout debugging, use screenshots plus accessibility snapshots with boxes and computed-style inspection. Screenshots show symptoms; boxes/computed styles identify the CSS rule causing the layout.
7575+- The app renders every route inside a single `#[layout(AppShell)]` shell (`src/shell.rs`) with an `Outlet`; the shell owns the global header (wordmark, Browse, global search → `/search`, Publish CTA, session/account control) and the viewport height (`min-height: 100dvh`, flex column), so pages are `flex: 1` and never set `min-height: 100vh`. Product nav deliberately excludes `/foundation` and `/viewer` (reachable by direct URL only). Sign-in `return_to` is the current route, not a hardcoded path.
7676+- `assets/styling/` follows a layered cascade, declared in that order in `src/main.rs`: tokens (`theme.css`) → reset/base (`base.css`) → reusable primitives (`primitives.css`) → shell chrome (`shell.css`) → per-route page styles (`home.css`, `thing-detail.css`, `profile.css`, `viewer.css`, `placeholders.css`). Keep `theme.css` and `DESIGN.md` in sync (the design detector reads `DESIGN.md`).
75777678### Lexicons & codegen
7779
+6
Cargo.toml
···149149 "fmt",
150150 "env-filter",
151151] }
152152+# Mirror the optional server-only deps so the `#[cfg(any(feature = "server",
153153+# test))]` server modules (indexing/appview/oauth) compile and their tests run
154154+# under a plain `cargo test`, not only `--features server`.
155155+axum-extra = { version = "0.10", features = ["cookie"] }
156156+base64 = "0.22"
157157+keyring = "3"
152158153159[build-dependencies]
154160dotenvy = "0.15"
+5
DESIGN.md
···3636rounded:
3737 panel: "2px"
3838 square: "0px"
3939+ pill: "999px"
3940spacing:
4041 base: "16px"
4142components:
···148149### Shadow Vocabulary
149150- **Paper hairline** (`box-shadow: 0 1px 0 color-mix(in srgb, var(--color-text) 8%, transparent)`): The single resting elevation on cards/panels. Borders do the structural work; this only seats the sheet.
150151152152+### Overlay tokens
153153+- Scheme-independent lighting overlays for the 3D viewer stage and SVG preview live as tokens (`--overlay-highlight-soft`, `--overlay-highlight`, `--overlay-shadow`). They are pure white/black at low alpha (highlight/shadow) and read correctly in both schemes; call sites use the token, never an inline `rgb(...)`.
154154+151155### Named Rules
152156**The Border-Over-Shadow Rule.** Structure is drawn with 1px borders and background-tone steps, never with soft drop shadows. Never pair a 1px border with a wide (≥16px) soft shadow on the same element (the ghost-card tell).
153157···162166### Chips / Tabs / Status pills
163167- **Style:** 1px border, near-square, mono text, ~0.72rem. Transparent at rest.
164168- **State:** Active tab / selected pill take a teal border, deep-teal text, and an 8% teal wash. Muted and error variants recolor the border + text + faint wash to match the semantic role.
169169+- **Pill radius:** `--radius-pill` (999px) is reserved for genuinely round badges — the circular part-index marker and deliberate tag chips — never for cards, inputs, or buttons.
165170166171### Cards / Containers
167172- **Corner Style:** 2px (panel radius).
···230230pub(crate) fn ProfileSelf() -> Element {
231231 let client = use_context::<PolymodelClient>();
232232 let session = use_context::<Signal<SessionIdentity>>();
233233+ // Sign-in must return the user to the page they started on; here that is
234234+ // the current profile route rather than a hardcoded path.
235235+ let return_to = use_route::<Route>().to_string();
233236 // Re-check the session on mount — the App-level seed runs once and may have
234237 // failed or not resolved yet. This ensures /profile always has a fresh read.
235238 let session_check = use_resource(move || {
···256259 if session_check.pending() {
257260 return rsx! {
258261 main { class: "profile-page product-shell",
259259- nav { class: "profile-nav", aria_label: "Profile navigation",
260260- Link { to: Route::Browse {}, "← Back to browse" }
261261- }
262262 section { class: "profile-shell blueprint-panel",
263263+ h1 { class: "sr-only", "Loading profile" }
263264 div { class: "state-card loading-state",
264265 span { class: "status-pill", "Loading" }
265266 div { class: "skeleton-line skeleton-wide" }
···277278 }
278279 SessionIdentity::Anonymous => rsx! {
279280 main { class: "profile-page product-shell",
280280- nav { class: "profile-nav", aria_label: "Profile navigation",
281281- Link { to: Route::Browse {}, "← Back to browse" }
282282- }
283281 section { class: "profile-sign-in blueprint-panel",
284282 span { class: "status-pill", "Signed out" }
285283 h1 { "Your profile" }
···288286 class: "session-control",
289287 action: "/oauth/start",
290288 method: "get",
291291- input { r#type: "hidden", name: "return_to", value: "/profile" },
289289+ input { r#type: "hidden", name: "return_to", value: "{return_to}" },
292290 input {
293291 r#type: "text",
294292 name: "identifier",
···321319322320 rsx! {
323321 main { class: "profile-page product-shell",
324324- nav { class: "profile-nav", aria_label: "Profile navigation",
325325- Link { to: Route::Browse {}, "← Back to browse" }
326326- }
327322 match state {
328323 ProfileState::Loading => rsx! { ProfileLoading {} },
329324 ProfileState::InvalidActor(message) => rsx! {
···370365fn ProfileLoading() -> Element {
371366 rsx! {
372367 section { class: "profile-shell blueprint-panel", aria_label: "Profile loading",
368368+ h1 { class: "sr-only", "Loading profile" }
373369 div { class: "state-card loading-state",
374370 span { class: "status-pill", "Loading" }
375371 div { class: "skeleton-line skeleton-wide" }
···440436 active_tab: Signal<ProfileTab>,
441437) -> Element {
442438 let header = data.header;
439439+ let return_to = use_route::<Route>().to_string();
443440 let things = data.things;
444441 let display_label = header_display_label(&header);
445442 let handle_str = header.handle.as_ref().to_string();
···502499 button { class: "button button-secondary", disabled: true, "Edit profile" }
503500 } else if is_anonymous {
504501 form { class: "profile-follow-form", action: "/oauth/start", method: "get",
505505- input { r#type: "hidden", name: "return_to", value: "/profile/{handle_str}" }
502502+ input { r#type: "hidden", name: "return_to", value: "{return_to}" }
506503 button { class: "button button-primary", r#type: "submit", "Sign in to follow" }
507504 }
508505 } else {
+55
src/publish.rs
···11+use dioxus::prelude::*;
22+33+use crate::Route;
44+use crate::session::SessionIdentity;
55+66+/// Honest placeholder for the publish flow. The wizard (PM-43) is not built
77+/// yet, so this route reserves the surface and prompts anonymous users to sign
88+/// in with a `return_to` of `/publish` — never a faked upload form.
99+#[allow(clippy::useless_format)]
1010+#[component]
1111+pub(crate) fn Publish() -> Element {
1212+ let session = use_context::<Signal<SessionIdentity>>();
1313+ let return_to = use_route::<Route>().to_string();
1414+ let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous);
1515+1616+ rsx! {
1717+ main { class: "publish-page",
1818+ section { class: "publish-intro blueprint-panel", aria_label: "Publish",
1919+ span { class: "status-pill status-muted", "Coming soon" }
2020+ h1 { "Publish a thing" }
2121+ p { class: "product-lede", "The flow for uploading models, parts, and files is not built yet. This route reserves the space so the product shell stays honest about what exists." }
2222+ }
2323+2424+ if is_anonymous {
2525+ section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in to publish",
2626+ span { class: "status-pill", "Signed out" }
2727+ h2 { "Sign in to publish" }
2828+ p { "Publishing needs an ATProto account. Sign in now and you return here, ready for when the flow ships." }
2929+ form {
3030+ class: "session-control",
3131+ action: "/oauth/start",
3232+ method: "get",
3333+ input { r#type: "hidden", name: "return_to", value: "{return_to}" },
3434+ input {
3535+ r#type: "text",
3636+ name: "identifier",
3737+ placeholder: "handle.test",
3838+ aria_label: "ATProto handle for publishing",
3939+ }
4040+ button { class: "button button-primary", r#type: "submit", "Sign in" }
4141+ }
4242+ }
4343+ } else {
4444+ section { class: "publish-steps blueprint-panel", aria_label: "Publish outline",
4545+ h2 { "What the flow will cover" }
4646+ ol { class: "instruction-list",
4747+ li { "Describe the thing: title, summary, tags, and license." }
4848+ li { "Add models and upload their ordered parts and files." }
4949+ li { "Review, then publish to your ATProto repository." }
5050+ }
5151+ }
5252+ }
5353+ }
5454+ }
5555+}
+28
src/search.rs
···11+use dioxus::prelude::*;
22+33+use crate::Route;
44+55+/// Honest placeholder for directed catalog search. The global shell search
66+/// field routes here with `?q=`. Search indexing and filtering are not built
77+/// yet, so this page captures the query without ever fabricating results.
88+#[allow(clippy::useless_format)]
99+#[component]
1010+pub(crate) fn Search(q: String) -> Element {
1111+ let query = q.trim().to_string();
1212+1313+ rsx! {
1414+ main { class: "search-page",
1515+ section { class: "search-intro blueprint-panel", aria_label: "Search",
1616+ span { class: "status-pill status-muted", "Not wired up yet" }
1717+ h1 { "Search" }
1818+ if query.is_empty() {
1919+ p { class: "product-lede", "Directed catalog search is on the way. Run a query from the header and it will be captured here." }
2020+ } else {
2121+ p { class: "product-lede", "Searched for \"{query}\". Search indexing is not built yet, so there are no results to show — the query is captured here rather than faked." }
2222+ }
2323+ p { class: "search-note", "Until search ships, browse the catalog from the home feed." }
2424+ Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" }
2525+ }
2626+ }
2727+ }
2828+}
+81-1
src/shell.rs
···44use crate::auth::{ToastState, broadcast_sign_out, logout_url};
55use crate::session::SessionIdentity;
6677+/// Global product shell. Renders the persistent header (wordmark, primary nav,
88+/// global directed-search field, Publish CTA, and session/account control) once
99+/// and hosts every route through the router `Outlet`. Pages render their own
1010+/// `<main>`/`<h1>` inside the outlet, so the shell deliberately uses a `<div>`
1111+/// content region rather than a competing `<main>`.
1212+#[component]
1313+pub(crate) fn AppShell() -> Element {
1414+ let session = use_context::<Signal<SessionIdentity>>();
1515+ let toasts = use_context::<Signal<ToastState>>();
1616+1717+ rsx! {
1818+ div { class: "app-shell",
1919+ header { class: "app-header",
2020+ div { class: "app-header-inner",
2121+ Link {
2222+ class: "app-wordmark",
2323+ to: Route::Browse {},
2424+ aria_label: "Polymodel home",
2525+ "Polymodel"
2626+ }
2727+ nav { class: "app-nav", aria_label: "Primary",
2828+ Link { class: "app-nav-link", active_class: "is-active", to: Route::Browse {}, "Browse" }
2929+ }
3030+ ShellSearch {}
3131+ Link { class: "button button-primary app-publish-cta", to: Route::Publish {}, "Publish" }
3232+ SessionControl { session, toasts }
3333+ }
3434+ }
3535+ div { class: "app-content",
3636+ Outlet::<Route> {}
3737+ }
3838+ }
3939+ }
4040+}
4141+4242+/// Global directed-search field. Submits client-side through the navigator to
4343+/// the `/search` route; it never performs a full document navigation (contrast
4444+/// `SessionControl`, which issues a real GET to `/oauth/start`).
4545+#[component]
4646+fn ShellSearch() -> Element {
4747+ let mut query = use_signal(String::new);
4848+4949+ rsx! {
5050+ div { class: "app-search", role: "search",
5151+ input {
5252+ class: "app-search-input",
5353+ r#type: "search",
5454+ name: "q",
5555+ placeholder: "Search the catalog",
5656+ aria_label: "Search Polymodel",
5757+ value: "{query}",
5858+ oninput: move |event| query.set(event.value()),
5959+ onkeydown: move |event| {
6060+ if event.key() == Key::Enter {
6161+ event.prevent_default();
6262+ let q = query.read().trim().to_string();
6363+ navigator().push(Route::Search { q });
6464+ }
6565+ },
6666+ }
6767+ button {
6868+ class: "button button-secondary app-search-button",
6969+ r#type: "button",
7070+ onclick: move |_| {
7171+ let q = query.read().trim().to_string();
7272+ navigator().push(Route::Search { q });
7373+ },
7474+ "Search"
7575+ }
7676+ }
7777+ }
7878+}
7979+8080+/// Returns the current route as a path string for use as an OAuth `return_to`
8181+/// value, so signing in returns the user to the page they started on.
8282+fn current_return_to() -> String {
8383+ use_route::<Route>().to_string()
8484+}
8585+786#[allow(clippy::useless_format)]
887#[component]
988pub(crate) fn SessionControl(
···1190 toasts: Signal<ToastState>,
1291) -> Element {
1392 let identity = session.read().clone();
9393+ let return_to = current_return_to();
14941595 match identity {
1696 SessionIdentity::Anonymous => rsx! {
···1898 class: "session-control",
1999 action: "/oauth/start",
20100 method: "get",
2121- input { r#type: "hidden", name: "return_to", value: "/" },
101101+ input { r#type: "hidden", name: "return_to", value: "{return_to}" },
22102 input {
23103 r#type: "text",
24104 name: "identifier",
+1-3
src/thing_detail.rs
···179179180180 rsx! {
181181 main { class: "thing-detail-page product-shell",
182182- nav { class: "detail-nav", aria_label: "Thing detail navigation",
183183- Link { to: Route::Browse {}, "← Back to browse" }
184184- }
185182 match state {
186183 DetailState::Loading => rsx! { DetailLoading {} },
187184 DetailState::InvalidRoute(message) => rsx! { DetailError { title: "Thing route unavailable".to_string(), message } },
···197194fn DetailLoading() -> Element {
198195 rsx! {
199196 section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing detail loading",
197197+ h1 { class: "sr-only", "Loading thing" }
200198 div { class: "state-card loading-state",
201199 span { class: "status-pill", "Loading" }
202200 div { class: "skeleton-line skeleton-wide" }
-4
src/viewer_route.rs
···11use dioxus::prelude::*;
2233-use crate::Route;
43use crate::viewer::{ViewerPage, demo_model_for_route};
5465#[component]
···98109 rsx! {
1110 main { class: "viewer-route-page",
1212- nav { class: "viewer-route-nav", aria_label: "Viewer navigation",
1313- Link { to: Route::Browse {}, "← Back to browse" }
1414- }
1511 ViewerPage { key: "{model.id}", initial_model: model }
1612 }
1713 }