From 4f9fe3dba6143997973ce88e94f0d1856447cca4 Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 22 Jun 2026 22:48:01 -0400 Subject: [PATCH] PM-39: product shell, discovery home, CSS reorg --- AGENTS.md | 2 + Cargo.toml | 6 + DESIGN.md | 5 + assets/styling/base.css | 3 +- assets/styling/home.css | 108 +++++++ assets/styling/placeholders.css | 44 +++ assets/styling/{cards.css => primitives.css} | 188 +++++++++--- assets/styling/profile.css | 131 +------- assets/styling/shell.css | 280 ++++++++++++++++++ assets/styling/theme.css | 8 + .../styling/{browse.css => thing-detail.css} | 245 +-------------- assets/styling/{polymodel.css => viewer.css} | 17 +- e2e/tests/home.spec.ts | 60 +++- e2e/tests/viewer.spec.ts | 23 +- src/browse.rs | 94 +++--- src/foundation.rs | 17 +- src/indexing/sample_data.rs | 97 +++++- src/main.rs | 37 ++- src/profile.rs | 19 +- src/publish.rs | 55 ++++ src/search.rs | 28 ++ src/shell.rs | 82 ++++- src/thing_detail.rs | 4 +- src/viewer_route.rs | 4 - 24 files changed, 1025 insertions(+), 532 deletions(-) create mode 100644 assets/styling/home.css create mode 100644 assets/styling/placeholders.css rename assets/styling/{cards.css => primitives.css} (62%) create mode 100644 assets/styling/shell.css rename assets/styling/{browse.css => thing-detail.css} (52%) rename assets/styling/{polymodel.css => viewer.css} (86%) create mode 100644 src/publish.rs create mode 100644 src/search.rs diff --git a/AGENTS.md b/AGENTS.md index 754ee2e..7a45b24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ Before review or handoff, run at least `just fix` and the narrowest relevant tes - 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. - 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. - 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. +- 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. +- `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`). ### Lexicons & codegen diff --git a/Cargo.toml b/Cargo.toml index 017d5a4..9267390 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,6 +149,12 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [ "fmt", "env-filter", ] } +# Mirror the optional server-only deps so the `#[cfg(any(feature = "server", +# test))]` server modules (indexing/appview/oauth) compile and their tests run +# under a plain `cargo test`, not only `--features server`. +axum-extra = { version = "0.10", features = ["cookie"] } +base64 = "0.22" +keyring = "3" [build-dependencies] dotenvy = "0.15" diff --git a/DESIGN.md b/DESIGN.md index 952edbe..69c75fe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -36,6 +36,7 @@ typography: rounded: panel: "2px" square: "0px" + pill: "999px" spacing: base: "16px" components: @@ -148,6 +149,9 @@ Flat by default. Depth comes from crisp 1px borders and tonal layering (paper � ### Shadow Vocabulary - **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. +### Overlay tokens +- 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(...)`. + ### Named Rules **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). @@ -162,6 +166,7 @@ Flat by default. Depth comes from crisp 1px borders and tonal layering (paper � ### Chips / Tabs / Status pills - **Style:** 1px border, near-square, mono text, ~0.72rem. Transparent at rest. - **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. +- **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. ### Cards / Containers - **Corner Style:** 2px (panel radius). diff --git a/assets/styling/base.css b/assets/styling/base.css index 4833631..ea50c2b 100644 --- a/assets/styling/base.css +++ b/assets/styling/base.css @@ -76,7 +76,6 @@ textarea { to { opacity: 1; } } -.browse-page, -.watch-page { +.browse-page { animation: fade-in 0.2s ease-out; } diff --git a/assets/styling/home.css b/assets/styling/home.css new file mode 100644 index 0000000..706adb4 --- /dev/null +++ b/assets/styling/home.css @@ -0,0 +1,108 @@ +/* Home (discovery browse) + the /foundation dev route. Pages sit inside the + shell content region, so they flex to fill height instead of owning the + viewport with `min-height: 100vh`. */ + +.browse-page { + flex: 1; + min-height: 0; + width: 100%; + max-width: 1180px; + margin: 0 auto; + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); +} + +.browse-intro, +.foundation-intro { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.browse-intro h1, +.foundation-intro h1 { + margin: 0; + color: var(--color-emphasis); + letter-spacing: -0.03em; + font-size: clamp(2rem, 5vw, 3.25rem); + line-height: 1; +} + +.browse-feed { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.browse-results-grid, +.foundation-grid, +.state-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + scroll-margin-top: 1rem; +} + +/* Foundation dev route */ + +.foundation-hero { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); + gap: clamp(1rem, 3vw, 2rem); + padding: clamp(1rem, 3vw, 1.5rem); + scroll-margin-top: 1rem; +} + +.foundation-hero h2 { + margin: 0; + color: var(--color-emphasis); + letter-spacing: -0.03em; +} + +.foundation-hero-copy { + display: grid; + align-content: center; + gap: 0.9rem; +} + +.foundation-hero-copy p { + margin: 0; + color: var(--color-subtle); + max-width: 42rem; +} + +.blueprint-preview { + min-height: 18rem; + border: 1px solid var(--color-border); +} + +.blueprint-preview::before, +.blueprint-preview::after { + content: ""; + position: absolute; + inset: 18% 16%; + border: 1px solid var(--color-primary); +} + +.blueprint-preview::after { + inset: 32% 26%; + border-color: var(--color-action); +} + +.foundation-card { + display: grid; + grid-template-rows: auto 1fr; + min-width: 0; + overflow: hidden; +} + +@media (max-width: 820px) { + .foundation-hero { + grid-template-columns: 1fr; + } + + .browse-results-grid, + .foundation-grid, + .state-grid { + grid-template-columns: 1fr; + } +} diff --git a/assets/styling/placeholders.css b/assets/styling/placeholders.css new file mode 100644 index 0000000..ef0bda7 --- /dev/null +++ b/assets/styling/placeholders.css @@ -0,0 +1,44 @@ +/* Honest placeholder routes: /search and /publish. Reserved surfaces with + clearly-labeled "not built yet" content, inside the shell content region. */ + +.search-page, +.publish-page { + flex: 1; + min-height: 0; + width: 100%; + max-width: 56rem; + margin: 0 auto; + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.search-intro, +.publish-intro, +.publish-signed-out, +.publish-steps { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: clamp(1.25rem, 3vw, 2rem); +} + +.search-intro h1, +.publish-intro h1 { + margin: 0; + color: var(--color-emphasis); + letter-spacing: -0.03em; +} + +.publish-signed-out h2, +.publish-steps h2 { + margin: 0; + color: var(--color-emphasis); +} + +.search-note { + margin: 0; + color: var(--color-muted); + font-size: 0.9rem; +} diff --git a/assets/styling/cards.css b/assets/styling/primitives.css similarity index 62% rename from assets/styling/cards.css rename to assets/styling/primitives.css index 1f838ca..7665523 100644 --- a/assets/styling/cards.css +++ b/assets/styling/primitives.css @@ -1,4 +1,33 @@ -/* Reusable card, button, input, chip, and state primitives. */ +/* Reusable primitives: panels, cards, buttons, tabs, pills, skeletons, and the + shared blueprint vocabulary. Depends on theme.css tokens + base.css reset; + loads before shell and page layers. */ + +/* Page-shell flex utility for routes that stack their sections vertically. */ +.product-shell { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +/* Shared lede + eyebrow text. The mono eyebrow is for deliberate, occasional + use (One-Eyebrow rule), never stacked above every section. */ +.product-lede { + max-width: 44rem; + margin: 0.7rem 0 0; + color: var(--color-subtle); + font-size: clamp(1rem, 2vw, 1.18rem); +} + +.product-kicker, +.eyebrow { + color: var(--color-primary); + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Panels and cards */ .card-surface, .blueprint-panel, @@ -36,6 +65,8 @@ font-size: 0.74rem; } +/* Drafting-grid media wells and placeholders */ + .card-media, .blueprint-media, .blueprint-preview { @@ -53,6 +84,31 @@ padding: 0.75rem; } +.blueprint-axis, +.blueprint-dimension { + position: absolute; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--color-primary); +} + +.blueprint-axis-x { + right: 0.75rem; + bottom: 0.75rem; +} + +.blueprint-axis-y { + left: 0.75rem; + top: 0.75rem; +} + +.blueprint-dimension { + left: 0.75rem; + bottom: 0.75rem; +} + +/* Thing cards */ + .thing-card { display: grid; grid-template-rows: auto 1fr; @@ -155,6 +211,20 @@ margin: 0; } +/* Visually-hidden utility: keeps a heading/label in the accessibility tree + (e.g. a route's

during a loading skeleton) without rendering it. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .thing-card-action { cursor: default; color: var(--color-muted); @@ -164,8 +234,21 @@ text-transform: uppercase; } -.button, -.viewer-route-nav a { +/* Creator links on cards and thing detail */ + +.thing-card-creator-link, +.thing-detail-creator-link { + color: var(--color-secondary); +} + +.thing-card-creator-link:hover, +.thing-detail-creator-link:hover { + color: var(--color-link); +} + +/* Buttons */ + +.button { display: inline-flex; align-items: center; justify-content: center; @@ -183,8 +266,7 @@ text-decoration: none; } -.button:hover, -.viewer-route-nav a:hover { +.button:hover { border-color: var(--color-primary); color: var(--color-tertiary); text-decoration: none; @@ -210,45 +292,29 @@ background: transparent; } -.button-row, -.tab-row, -.product-nav { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; +.button:disabled, +.button[disabled] { + cursor: not-allowed; + opacity: 0.52; + filter: saturate(0.7); } -.search-field { - display: grid; - gap: 0.35rem; - color: var(--color-subtle); - font-size: 0.85rem; -} - -.search-field span, -.eyebrow, -.product-kicker { - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.search-field input { - width: 100%; - padding: 0.75rem 0.85rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-panel); - background: var(--color-base); +.button:disabled:hover, +.button[disabled]:hover { + border-color: var(--color-border); color: var(--color-text); - font-family: var(--font-ui); + filter: saturate(0.7); } -.search-field input:focus { - outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); - border-color: var(--color-primary); +.button-row, +.tab-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; } +/* Tabs, status pills */ + .tab, .status-pill { display: inline-flex; @@ -271,6 +337,13 @@ background: color-mix(in srgb, var(--color-primary) 8%, transparent); } +.tab:disabled, +.tab[disabled] { + cursor: not-allowed; + opacity: 0.52; + filter: saturate(0.7); +} + .status-muted { border-color: var(--color-muted); color: var(--color-muted); @@ -283,6 +356,47 @@ background: color-mix(in srgb, var(--color-error) 8%, transparent); } +/* Reusable state cards (loading / empty / error) */ + +.state-card { + display: grid; + align-content: start; + gap: 0.75rem; + min-height: 11rem; + padding: 1rem; +} + +.state-card h2 { + margin: 0; + color: var(--color-emphasis); + letter-spacing: -0.03em; +} + +.state-card p { + margin: 0; + color: var(--color-subtle); +} + +.error-state { + border-color: color-mix(in srgb, var(--color-error) 55%, var(--color-border)); +} + +/* Ordered lists (instructions, print settings) shared across detail + publish */ + +.instruction-list, +.print-settings { + margin: 0; + padding-left: 1.25rem; + color: var(--color-text); +} + +.instruction-list li + li, +.print-settings li + li { + margin-top: 0.35rem; +} + +/* Skeletons */ + .skeleton-line, .skeleton-box { background: linear-gradient(90deg, var(--color-overlay), var(--color-highlight), var(--color-overlay)); diff --git a/assets/styling/profile.css b/assets/styling/profile.css index 37daf11..61368ce 100644 --- a/assets/styling/profile.css +++ b/assets/styling/profile.css @@ -1,22 +1,16 @@ -/* Profile page: two-column identity rail + content column. - Reuses blueprint tokens (color-*, font-mono, status-pill, tab, state-card, - card-surface, button) from theme/base/browse/cards CSS. */ +/* Profile route: two-column identity rail + content column. Reuses blueprint + primitives (status-pill, tab, state-card, card-surface, button) and the shell + layer for account chrome. Sits inside the shell content region. */ .profile-page { + flex: 1; + min-height: 0; + width: 100%; max-width: 1180px; margin: 0 auto; padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); } -.profile-nav { - margin-bottom: 0.9rem; -} - -.profile-nav a { - color: var(--color-secondary); - font-size: 0.875rem; -} - .profile-shell { display: flex; flex-direction: column; @@ -283,116 +277,3 @@ margin: 0; color: var(--color-emphasis); } - -/* Creator links on cards and detail */ - -.thing-card-creator-link, -.thing-detail-creator-link { - color: var(--color-secondary); -} - -.thing-card-creator-link:hover, -.thing-detail-creator-link:hover { - color: var(--color-link); -} - -/* Signed-in account chrome: avatar trigger + click-to-open dropdown menu. */ - -.account-menu { - position: relative; - display: inline-flex; - align-items: center; -} - -.account-menu > summary { - display: inline-flex; - align-items: center; - cursor: pointer; - list-style: none; -} - -.account-menu > summary::-webkit-details-marker { - display: none; -} - -.account-menu > summary:focus-visible { - outline: 2px solid color-mix(in srgb, var(--color-primary) 35%, transparent); - outline-offset: 2px; -} - -.session-avatar { - display: block; - width: 2.12rem; - height: 2.12rem; - object-fit: cover; - border: 1px solid var(--color-border); - flex-shrink: 0; -} - -.session-avatar-fallback { - display: flex; - align-items: center; - justify-content: center; - background-color: var(--color-overlay); - color: var(--color-primary); - font-weight: 700; - font-size: 0.9rem; -} - -.account-menu-panel { - position: absolute; - right: 0; - top: calc(100% + 0.4rem); - z-index: 30; - min-width: 13rem; - display: flex; - flex-direction: column; - gap: 0.1rem; - padding: 0.4rem; -} - -.account-menu-identity { - display: flex; - flex-direction: column; - gap: 0.05rem; - padding: 0.35rem 0.5rem 0.5rem; - margin-bottom: 0.2rem; - border-bottom: 1px dashed var(--color-border); -} - -.account-menu-identity strong { - color: var(--color-text); -} - -.account-menu-handle { - color: var(--color-muted); - font-size: 0.8rem; - word-break: break-word; -} - -.account-menu-form { - margin: 0; -} - -.account-menu-item { - display: block; - width: 100%; - box-sizing: border-box; - padding: 0.4rem 0.5rem; - text-align: left; - background: transparent; - border: 0; - color: var(--color-text); - font-family: var(--font-ui); - font-size: 0.9rem; - cursor: pointer; -} - -.account-menu-item:hover { - background-color: var(--color-overlay); - text-decoration: none; -} - -.account-menu-signout { - color: var(--color-error); -} diff --git a/assets/styling/shell.css b/assets/styling/shell.css new file mode 100644 index 0000000..ceea1aa --- /dev/null +++ b/assets/styling/shell.css @@ -0,0 +1,280 @@ +/* Product shell layer: the persistent global header and the session/account + chrome that now lives in it. The shell owns viewport height; pages are + `flex: 1` inside the content region (see page layers). */ + +.app-shell { + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +.app-header { + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.app-header-inner { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: clamp(0.6rem, 2vw, 1.25rem); + width: 100%; + max-width: 1180px; + margin: 0 auto; + padding: 0.6rem clamp(1rem, 3vw, 2.5rem); +} + +.app-wordmark { + font-family: var(--font-heading, var(--font-ui)); + font-weight: 700; + font-size: 1.15rem; + letter-spacing: -0.02em; + color: var(--color-emphasis); + text-decoration: none; +} + +.app-wordmark:hover { + text-decoration: none; + color: var(--color-primary); +} + +.app-nav { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.app-nav-link { + padding: 0.4rem 0.55rem; + border: 1px solid transparent; + border-radius: var(--radius-panel); + color: var(--color-subtle); + font-weight: 600; +} + +.app-nav-link:hover { + text-decoration: none; + border-color: var(--color-primary); + color: var(--color-tertiary); +} + +.app-nav-link.is-active { + border-color: var(--color-primary); + color: var(--color-tertiary); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); +} + +/* Global directed-search field (navigates client-side to /search). */ +.app-search { + display: flex; + align-items: stretch; + flex: 1 1 14rem; + min-width: 8rem; +} + +.app-search-input { + flex: 1; + min-width: 0; + min-height: 2.1rem; + padding: 0.45rem 0.65rem; + border: 1px solid var(--color-border); + border-right: 0; + border-radius: var(--radius-panel) 0 0 var(--radius-panel); + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.9rem; +} + +.app-search-input:focus { + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + outline-offset: 1px; + border-color: var(--color-primary); +} + +.app-search-button { + min-height: 2.1rem; + border-radius: 0 var(--radius-panel) var(--radius-panel) 0; +} + +.app-publish-cta { + white-space: nowrap; +} + +.app-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* Anonymous sign-in control (header + page-level reuse). */ +.session-control { + display: inline-flex; + align-items: stretch; + gap: 0; + flex-wrap: nowrap; +} + +.session-control input { + width: min(9.5rem, 42vw); + min-height: 2rem; + padding: 0.45rem 0.65rem; + border: 1px solid var(--color-border); + border-right: 0; + border-radius: var(--radius-panel) 0 0 var(--radius-panel); + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.9rem; +} + +.session-control input:focus { + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + outline-offset: 1px; + border-color: var(--color-primary); +} + +.session-control .button { + min-height: 2rem; + border-radius: 0 var(--radius-panel) var(--radius-panel) 0; +} + +/* Signed-in account chrome: avatar trigger + click-to-open dropdown menu. */ + +.account-menu { + position: relative; + display: inline-flex; + align-items: center; +} + +.account-menu > summary { + display: inline-flex; + align-items: center; + cursor: pointer; + list-style: none; +} + +.account-menu > summary::-webkit-details-marker { + display: none; +} + +.account-menu > summary:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-primary) 35%, transparent); + outline-offset: 2px; +} + +.session-avatar { + display: block; + width: 2.12rem; + height: 2.12rem; + object-fit: cover; + border: 1px solid var(--color-border); + flex-shrink: 0; +} + +.session-avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-overlay); + color: var(--color-primary); + font-weight: 700; + font-size: 0.9rem; +} + +.account-menu-panel { + position: absolute; + right: 0; + top: calc(100% + 0.4rem); + z-index: 30; + min-width: 13rem; + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.4rem; +} + +.account-menu-identity { + display: flex; + flex-direction: column; + gap: 0.05rem; + padding: 0.35rem 0.5rem 0.5rem; + margin-bottom: 0.2rem; + border-bottom: 1px dashed var(--color-border); +} + +.account-menu-identity strong { + color: var(--color-text); +} + +.account-menu-handle { + color: var(--color-muted); + font-size: 0.8rem; + word-break: break-word; +} + +.account-menu-form { + margin: 0; +} + +.account-menu-item { + display: block; + width: 100%; + box-sizing: border-box; + padding: 0.4rem 0.5rem; + text-align: left; + background: transparent; + border: 0; + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.9rem; + cursor: pointer; +} + +.account-menu-item:hover { + background-color: var(--color-overlay); + text-decoration: none; +} + +.account-menu-signout { + color: var(--color-error); +} + +/* Toast shelf (fixed feedback stack). */ +.toast-shelf { + position: fixed; + right: clamp(0.75rem, 3vw, 1.5rem); + bottom: clamp(0.75rem, 3vw, 1.5rem); + z-index: 40; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: min(22rem, 90vw); +} + +.toast { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.6rem 0.75rem; +} + +.toast-dismiss { + border: 0; + background: transparent; + color: var(--color-muted); + font-size: 1.1rem; + line-height: 1; + padding: 0 0.25rem; +} + +/* On narrow viewports the directed-search field drops to its own row so the + wordmark, Browse, Publish, and session control all stay visible. */ +@media (max-width: 640px) { + .app-search { + order: 5; + flex-basis: 100%; + } +} diff --git a/assets/styling/theme.css b/assets/styling/theme.css index 36c40a9..c990d80 100644 --- a/assets/styling/theme.css +++ b/assets/styling/theme.css @@ -79,6 +79,14 @@ --spacing-line-height: 1.55; --spacing-scale: 1.25; --radius-panel: 2px; + --radius-pill: 999px; + + /* Scheme-independent lighting overlays for the 3D viewer stage and SVG + preview. Pure white/black at low alpha read as highlight/shadow in both + schemes; defined as tokens so call sites never inline raw rgb(). */ + --overlay-highlight-soft: rgb(255 255 255 / 0.06); + --overlay-highlight: rgb(255 255 255 / 0.12); + --overlay-shadow: rgb(0 0 0 / 0.22); } @media (prefers-color-scheme: dark) { diff --git a/assets/styling/browse.css b/assets/styling/thing-detail.css similarity index 52% rename from assets/styling/browse.css rename to assets/styling/thing-detail.css index a38a97c..8c15b89 100644 --- a/assets/styling/browse.css +++ b/assets/styling/thing-detail.css @@ -1,214 +1,15 @@ -/* Browse foundation layout. */ - -.browse-page { - min-height: 100vh; - max-width: 1180px; - margin: 0 auto; - padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); -} - -.product-shell { - display: flex; - flex-direction: column; - gap: 0.9rem; -} - -.product-header { - display: grid; - align-content: start; - gap: 0.6rem; - padding-block: 0.25rem 0; -} - -.product-kicker, -.eyebrow { - color: var(--color-primary); -} - -.product-header-row { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 1rem; -} - -.product-header h1, -.foundation-hero h2, -.state-card h2 { - margin: 0; - color: var(--color-emphasis); - letter-spacing: -0.03em; -} - -.product-header h1 { - font-size: clamp(2rem, 5vw, 3.75rem); - line-height: 0.95; -} - -.product-lede { - max-width: 44rem; - margin: 0.7rem 0 0; - color: var(--color-subtle); - font-size: clamp(1rem, 2vw, 1.18rem); -} - -.product-nav { - justify-content: flex-end; - align-items: center; -} - -.product-nav a:not(.button) { - color: var(--color-subtle); - font-weight: 600; - padding: 0.45rem 0; -} -.session-control { - display: inline-flex; - align-items: stretch; - gap: 0; - flex-wrap: nowrap; -} -.session-control input { - width: min(9.5rem, 42vw); - min-height: 2rem; - padding: 0.45rem 0.65rem; - border: 1px solid var(--color-border); - border-right: 0; - border-radius: var(--radius-sm) 0 0 var(--radius-sm); - background: var(--color-surface); - color: var(--color-text); - font-family: var(--font-ui); - font-size: 0.9rem; -} -.session-control input:focus { - outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); - outline-offset: 1px; - border-color: var(--color-primary); -} -.session-control .button { - min-height: 2rem; - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; -} -.session-control-authenticated { - justify-content: flex-end; - gap: 0.5rem; -} -.foundation-hero { - display: grid; - grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); - gap: clamp(1rem, 3vw, 2rem); - padding: clamp(1rem, 3vw, 1.5rem); - scroll-margin-top: 1rem; -} - -.foundation-hero-copy { - display: grid; - align-content: center; - gap: 0.9rem; -} - -.foundation-hero-copy p { - margin: 0; - color: var(--color-subtle); - max-width: 42rem; -} - -.blueprint-preview { - min-height: 18rem; - border: 1px solid var(--color-border); -} - -.blueprint-preview::before, -.blueprint-preview::after { - content: ""; - position: absolute; - inset: 18% 16%; - border: 1px solid var(--color-primary); -} - -.blueprint-preview::after { - inset: 32% 26%; - border-color: var(--color-action); -} - -.blueprint-axis, -.blueprint-dimension { - position: absolute; - font-family: var(--font-mono); - font-size: 0.72rem; - color: var(--color-primary); -} - -.blueprint-axis-x { - right: 0.75rem; - bottom: 0.75rem; -} -.blueprint-axis-y { - left: 0.75rem; - top: 0.75rem; -} -.blueprint-dimension { - left: 0.75rem; - bottom: 0.75rem; -} - -.browse-controls { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 1rem; - align-items: end; - padding: 1rem; -} - -.browse-results-grid, -.foundation-grid, -.state-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1rem; - scroll-margin-top: 1rem; -} - -.foundation-card { - display: grid; - grid-template-rows: auto 1fr; - min-width: 0; - overflow: hidden; -} - -.state-card { - display: grid; - align-content: start; - gap: 0.75rem; - min-height: 11rem; - padding: 1rem; -} - -.state-card p { - margin: 0; - color: var(--color-subtle); -} - -.error-state { - border-color: color-mix(in srgb, var(--color-error) 55%, var(--color-border)); -} +/* Thing-detail route: header, viewer well, model/part panels. Sits inside the + shell content region (flex height, not `min-height: 100vh`). */ .thing-detail-page { - min-height: 100vh; + flex: 1; + min-height: 0; + width: 100%; max-width: 1180px; margin: 0 auto; padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); } -.detail-nav { - display: flex; -} - -.detail-nav a { - color: var(--color-subtle); - font-weight: 600; -} - .thing-detail-header, .thing-detail-summary, .thing-detail-copy, @@ -352,8 +153,6 @@ min-width: 9.5rem; } -.button:disabled, -.button[disabled], .model-row:disabled, .model-row[disabled] { cursor: not-allowed; @@ -361,13 +160,6 @@ filter: saturate(0.7); } -.button:disabled:hover, -.button[disabled]:hover { - border-color: var(--color-border); - color: var(--color-text); - filter: saturate(0.7); -} - .thing-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); @@ -402,18 +194,6 @@ gap: 0.3rem; } -.instruction-list, -.print-settings { - margin: 0; - padding-left: 1.25rem; - color: var(--color-text); -} - -.instruction-list li + li, -.print-settings li + li { - margin-top: 0.35rem; -} - .model-list { display: flex; flex-direction: column; @@ -478,7 +258,7 @@ width: 2rem; height: 2rem; border: 1px solid var(--color-primary); - border-radius: 999px; + border-radius: var(--radius-pill); color: var(--color-primary); font-family: var(--font-mono); font-size: 0.78rem; @@ -496,26 +276,13 @@ } @media (max-width: 820px) { - .foundation-hero, - .browse-controls, .thing-detail-shell, .thing-detail-grid { grid-template-columns: 1fr; } - .product-header-row, .thing-detail-title-row { display: grid; align-items: start; } - - .product-nav { - justify-content: flex-start; - } - - .browse-results-grid, - .foundation-grid, - .state-grid { - grid-template-columns: 1fr; - } } diff --git a/assets/styling/polymodel.css b/assets/styling/viewer.css similarity index 86% rename from assets/styling/polymodel.css rename to assets/styling/viewer.css index 3b240fc..2b3a171 100644 --- a/assets/styling/polymodel.css +++ b/assets/styling/viewer.css @@ -1,15 +1,14 @@ +/* Standalone demo viewer route + the retained WebGL model-viewer surface. + Sits inside the shell content region (flex height, not `min-height: 100vh`). */ + .viewer-route-page { - min-height: 100vh; + flex: 1; + min-height: 0; padding: clamp(1rem, 4vw, 3rem); background: var(--color-base); color: var(--color-text); } -.viewer-route-nav { - margin: 0 auto 1rem; - max-width: 72rem; -} - .model-viewer-shell { display: grid; gap: 1rem; @@ -87,8 +86,8 @@ display: grid; grid-template-rows: minmax(24rem, min(56vh, 34rem)) auto; background: - linear-gradient(135deg, rgb(255 255 255 / 0.06), transparent 30%), - radial-gradient(circle at 50% 35%, rgb(255 255 255 / 0.12), transparent 24%), + linear-gradient(135deg, var(--overlay-highlight-soft), transparent 30%), + radial-gradient(circle at 50% 35%, var(--overlay-highlight), transparent 24%), var(--color-base); } @@ -114,7 +113,7 @@ box-sizing: border-box; } -.model-preview-shadow { fill: rgb(0 0 0 / 0.22); } +.model-preview-shadow { fill: var(--overlay-shadow); } .model-preview-body { fill: color-mix(in srgb, var(--color-primary) 38%, transparent); stroke: var(--color-primary); stroke-width: 4; } .model-preview-cut { fill: color-mix(in srgb, var(--color-base) 80%, transparent); stroke: var(--color-border); stroke-width: 3; } .model-preview-line { fill: none; stroke: color-mix(in srgb, var(--color-primary) 70%, var(--color-text)); stroke-width: 3; } diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts index ff43c64..a39ddf6 100644 --- a/e2e/tests/home.spec.ts +++ b/e2e/tests/home.spec.ts @@ -1,13 +1,22 @@ import { expect, test } from '@playwright/test'; -test('home page shows the product browse shell with reusable thing cards', async ({ page }) => { +test('home page shows the product shell and discovery feed with reusable thing cards', async ({ page }) => { await page.goto('/'); - await expect(page.getByRole('heading', { name: 'Polymodel' })).toBeVisible(); - await expect(page.getByLabel('Browse shell')).toBeVisible(); - await expect(page.getByRole('tab', { name: 'Recent' })).toBeVisible(); - await expect(page.getByRole('tab', { name: 'Hot' })).toBeVisible(); - await expect(page.getByRole('searchbox', { name: 'Search model projects' })).toBeVisible(); + // Persistent product shell. + await expect(page.getByRole('link', { name: 'Polymodel home' })).toBeVisible(); + await expect(page.getByRole('searchbox', { name: 'Search Polymodel' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Publish' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + + // Discovery home heading + feed selector (Hot default, Following disabled). + await expect(page.getByRole('heading', { name: 'Browse the catalog' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Hot' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tab', { name: 'Recent' })).toHaveAttribute('aria-selected', 'false'); + await expect(page.getByRole('tab', { name: 'Following' })).toBeDisabled(); + + // No inline catalog search box on the home surface anymore. + await expect(page.getByRole('searchbox', { name: 'Search model projects' })).toHaveCount(0); const results = page.getByLabel('Browse results'); await expect(results).toBeVisible(); @@ -19,7 +28,6 @@ test('home page shows the product browse shell with reusable thing cards', async await expect(page.getByText('18 parts')).toBeVisible(); await expect(page.getByRole('img', { name: 'Rendered preview of a parametric electronics enclosure' })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Untitled thing' })).toBeVisible(); await expect(page.getByText('by mira.tools')).toBeVisible(); @@ -32,6 +40,44 @@ test('home page shows the product browse shell with reusable thing cards', async await expect(page.getByText('Empty')).toHaveCount(0); }); +test('global shell search navigates client-side to /search', async ({ page }) => { + await page.goto('/'); + + // Gate on a rendered feed card so the wasm client has hydrated and wired the + // search field's keydown handler before we type — otherwise Enter races + // hydration and is dropped (the SSR input accepts fill but no handler runs). + await expect(page.getByLabel('Browse results').getByRole('article').first()).toBeVisible(); + + const search = page.getByRole('searchbox', { name: 'Search Polymodel' }); + await search.fill('enclosure'); + await search.press('Enter'); + + await expect(page).toHaveURL(/\/search\?q=enclosure$/); + await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); + await expect(page.getByText('Not wired up yet')).toBeVisible(); + await expect(page.getByText(/Searched for/)).toBeVisible(); +}); + +test('shell header is present on a deep route', async ({ page }) => { + await page.goto('/profile'); + + await expect(page.getByRole('link', { name: 'Polymodel home' })).toBeVisible(); + await expect(page.getByRole('searchbox', { name: 'Search Polymodel' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Publish' })).toBeVisible(); +}); + +test('placeholder routes resolve with honest, non-faked content', async ({ page }) => { + await page.goto('/search'); + await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); + await expect(page.getByText('Not wired up yet')).toBeVisible(); + + await page.goto('/publish'); + await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible(); + await expect(page.getByText('Coming soon')).toBeVisible(); + // Anonymous publish prompts sign-in (returns the user to /publish). + await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible(); +}); + test('home thing cards remain readable at narrow widths', async ({ page }) => { await page.setViewportSize({ width: 390, height: 900 }); await page.goto('/'); diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index fe5b8d1..6efde2f 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -18,19 +18,20 @@ async function expectViewerReady(page) { await expect(page.locator(preview)).toHaveCount(0); } -test('viewer is reachable from browse and displays the committed STL asset', async ({ page }) => { - await page.goto('/'); - await page.getByRole('link', { name: 'Open viewer' }).click(); +test('the demo viewer route displays the committed STL asset inside the shell', async ({ page }) => { + await page.goto('/viewer/body-f-chest-v4'); await expect(page).toHaveURL(/\/viewer\/body-f-chest-v4$/); await expectViewerReady(page); + // The product shell wraps the demo viewer route (reachable by direct URL only). + await expect(page.getByRole('link', { name: 'Polymodel home' })).toBeVisible(); + // The selected model and ready status surface on the boundary's data attributes + // and in the visible header chrome (status pill + active model control). await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', chestAssetPath); - await expect(page.locator(boundary)).toContainText('Body F chest v4'); - await expect(page.locator(boundary)).toContainText(chestAssetPath); - await expect(page.locator(boundary)).toContainText('41051'); - await expect(page.locator(boundary)).toContainText('82122'); - await expect(page.locator(boundary)).toContainText('Viewer ready'); + await expect(page.locator(boundary)).toHaveAttribute('data-viewer-status', 'Viewer ready'); + await expect(page.getByText('Viewer ready')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Body F chest v4' })).toBeVisible(); }); test('direct viewer routes select deterministic asset-backed models', async ({ page }) => { @@ -38,13 +39,13 @@ test('direct viewer routes select deterministic asset-backed models', async ({ p await expectViewerReady(page); await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', chestAssetPath); - await expect(page.locator(boundary)).toContainText('Body F chest v4'); + await expect(page.getByRole('button', { name: 'Body F chest v4' })).toBeVisible(); await page.goto('/viewer/body-f-forearm-2x-v9'); await expectViewerReady(page); await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-forearm-2x-v9#1'); await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); - await expect(page.locator(boundary)).toContainText('Body F forearm 2x v9'); + await expect(page.getByRole('button', { name: 'Body F forearm 2x v9' })).toBeVisible(); }); test('model switching keeps one WebGL context and updates renderer session metadata', async ({ page }) => { @@ -59,8 +60,6 @@ test('model switching keeps one WebGL context and updates renderer session metad await expect(page.locator(canvas)).toHaveCount(1); await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-forearm-2x-v9#1'); await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); - await expect(page.locator(boundary)).toContainText('18271'); - await expect(page.locator(boundary)).toContainText('36542'); // Switching models must NOT acquire a second WebGL context (in-place replace). await expect.poll(acquisitions.bind(null, page)).toBe(1); diff --git a/src/browse.rs b/src/browse.rs index 0ed574b..381e0db 100644 --- a/src/browse.rs +++ b/src/browse.rs @@ -2,15 +2,10 @@ use dioxus::prelude::*; use jacquard_common::xrpc::XrpcClient; use polymodel_api::space_polymodel::library; -use crate::Route; -use crate::auth::ToastState; use crate::client::PolymodelClient; use crate::profile::profile_href; -use crate::session::SessionIdentity; -use crate::shell::SessionControl; use crate::thing_card::ThingCard; use crate::thing_detail::thing_detail_href; -use crate::viewer::demo_models; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FeedAlgorithm { @@ -61,11 +56,8 @@ fn browse_feed_state( #[allow(clippy::useless_format)] #[component] pub(crate) fn Browse() -> Element { - let viewer_model = demo_models()[0].id.to_string(); let client = use_context::(); - let session = use_context::>(); - let toasts = use_context::>(); - let selected_algorithm = use_signal(|| FeedAlgorithm::Recent); + let selected_algorithm = use_signal(|| FeedAlgorithm::Hot); let mut feed = use_resource(move || { let client = client.clone(); @@ -95,62 +87,50 @@ pub(crate) fn Browse() -> Element { let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); rsx! { - main { class: "browse-page product-shell", - header { class: "product-header", - div { class: "product-kicker", "Show off your creations" } - div { class: "product-header-row", - div { h1 { "Polymodel" } } - nav { class: "product-nav", aria_label: "Primary", - a { href: "#browse-shell", "Browse" } - Link { to: Route::Foundation {}, "Foundation" } - Link { class: "button button-secondary", to: Route::Viewer { model_id: viewer_model }, "Open viewer" } - SessionControl { session, toasts } - } - } + main { class: "browse-page", + header { class: "browse-intro", + h1 { "Browse the catalog" } + p { class: "product-lede", "Discover printable models and projects shared on ATProto. Pick a feed to start exploring." } } - section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", - label { class: "search-field", - span { "Go find something" } - input { r#type: "search", placeholder: "Search by project, tag, or maker", aria_label: "Search model projects" } - } + section { class: "browse-feed", aria_label: "Catalog feed", div { class: "tab-row", role: "tablist", aria_label: "Feeds", - FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } + FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } } - } - section { class: "browse-results-grid", aria_label: "Browse results", - match feed_state { - BrowseFeedState::Loading => rsx! { - for index in 0..6 { - div { key: "skeleton-{index}", class: "state-card loading-state", - span { class: "status-pill", "Loading" } - div { class: "skeleton-line skeleton-wide" } - div { class: "skeleton-line" } - div { class: "skeleton-box" } + section { class: "browse-results-grid", aria_label: "Browse results", + match feed_state { + BrowseFeedState::Loading => rsx! { + for index in 0..6 { + div { key: "skeleton-{index}", class: "state-card loading-state", + span { class: "status-pill", "Loading" } + div { class: "skeleton-line skeleton-wide" } + div { class: "skeleton-line" } + div { class: "skeleton-box" } + } + } + }, + BrowseFeedState::Error(error) => rsx! { + div { class: "state-card error-state browse-state-card", + p { "{error}" } + button { class: "button button-secondary", onclick: move |_| feed.restart(), "Retry" } + } + }, + BrowseFeedState::Empty => rsx! { + div { class: "state-card empty-state browse-state-card", + span { class: "status-pill status-muted", "Empty" } + h2 { "No projects yet" } + p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } } - } - }, - BrowseFeedState::Error(error) => rsx! { - div { class: "state-card error-state browse-state-card", - p { "{error}" } - button { class: "button button-secondary", onclick: move |_| feed.restart(), "Retry" } - } - }, - BrowseFeedState::Empty => rsx! { - div { class: "state-card empty-state browse-state-card", - span { class: "status-pill status-muted", "Empty" } - h2 { "No projects yet" } - p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } - } - }, - BrowseFeedState::Populated(things) => rsx! { - for thing in things { - BrowseThingCard { key: "{thing.uri}", thing } - } - }, + }, + BrowseFeedState::Populated(things) => rsx! { + for thing in things { + BrowseThingCard { key: "{thing.uri}", thing } + } + }, + } } } } diff --git a/src/foundation.rs b/src/foundation.rs index beeb334..0597c3f 100644 --- a/src/foundation.rs +++ b/src/foundation.rs @@ -1,23 +1,12 @@ use dioxus::prelude::*; -use crate::Route; - #[component] pub(crate) fn Foundation() -> Element { rsx! { main { class: "browse-page product-shell", - header { class: "product-header", - div { class: "product-kicker", "Front-end foundation" } - div { class: "product-header-row", - div { - h1 { "Foundation" } - p { class: "product-lede", "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." } - } - nav { class: "product-nav", aria_label: "Foundation navigation", - Link { to: Route::Browse {}, "Back to home" } - a { href: "#ui-states", "UI states" } - } - } + header { class: "foundation-intro", + h1 { "Foundation" } + p { class: "product-lede", "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." } } section { id: "browse-foundation", class: "foundation-hero blueprint-panel", diff --git a/src/indexing/sample_data.rs b/src/indexing/sample_data.rs index 3831673..28045d2 100644 --- a/src/indexing/sample_data.rs +++ b/src/indexing/sample_data.rs @@ -23,6 +23,14 @@ pub const SAMPLE_THING_URI: &str = const SAMPLE_TIME: &str = "2026-06-21T12:00:00.000Z"; const SAMPLE_TIME_MILLIS: i64 = 1_750_507_200_000; +// Additional demo makers so the discovery home shows a populated feed with +// mixed media states: one thing with a cover, two cover-less things that +// render the missing-media blueprint placeholder. +const MIRA_DID: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; +const MIRA_HANDLE: &str = "mira.tools"; +const FOUNDRY_DID: &str = "did:plc:cccccccccccccccccccccccc"; +const FOUNDRY_HANDLE: &str = "foundrylab.tools"; + pub async fn seed_if_empty(pool: &SqlitePool) -> anyhow::Result<()> { let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM things") .fetch_one(pool) @@ -143,6 +151,31 @@ async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { .execute(&mut *tx) .await?; + // Two more makers with cover-less things so the home feed is populated and + // the missing-media placeholder path renders against real projected rows. + insert_identity(&mut tx, MIRA_DID, MIRA_HANDLE).await?; + insert_identity(&mut tx, FOUNDRY_DID, FOUNDRY_HANDLE).await?; + insert_coverless_thing( + &mut tx, + MIRA_DID, + "field-prototype", + "Untitled thing", + "A work-in-progress upload with no preview rendered yet.", + 0, + 0, + ) + .await?; + insert_coverless_thing( + &mut tx, + FOUNDRY_DID, + "modular-calibration-tower", + "Modular calibration tower", + "A calibration tower for dialing in printer tolerances before a full run.", + 8, + 5, + ) + .await?; + tx.commit().await?; Ok(()) } @@ -311,6 +344,54 @@ fn sample_parts() -> Vec { .collect() } +async fn insert_coverless_thing( + conn: &mut SqliteConnection, + did: &str, + rkey: &str, + name: &str, + summary: &str, + like_count: i64, + save_count: i64, +) -> anyhow::Result<()> { + let uri = thing_uri(did, rkey); + let record = typed_json::(json!({ + "name": name, + "summary": summary, + "license": "CC-BY-4.0", + "createdAt": SAMPLE_TIME + }))?; + + sqlx::query( + r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json, + tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)"#, + ) + .bind(did) + .bind(rkey) + .bind(&uri) + .bind(format!("bafyreithing{}", rkey.replace('-', ""))) + .bind(name) + .bind(summary) + .bind("CC-BY-4.0") + .bind(SAMPLE_TIME_MILLIS) + .bind(SAMPLE_TIME_MILLIS) + .bind(record) + .execute(&mut *conn) + .await?; + + if like_count > 0 || save_count > 0 { + sqlx::query( + "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, ?, ?, 0)", + ) + .bind(&uri) + .bind(like_count) + .bind(save_count) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + fn model_uri(rkey: &str) -> String { format!("at://{SAMPLE_DID}/space.polymodel.library.model/{rkey}") } @@ -319,6 +400,10 @@ fn part_uri(rkey: &str) -> String { format!("at://{SAMPLE_DID}/space.polymodel.library.part/{rkey}") } +fn thing_uri(did: &str, rkey: &str) -> String { + format!("at://{did}/space.polymodel.library.thing/{rkey}") +} + fn strong_ref(uri: &str) -> Value { json!({ "uri": uri, "cid": "bafyreicid" }) } @@ -383,13 +468,23 @@ mod tests { let state = Arc::new(AppState::new(pool, bootstrap)); let feed = views::feed_recent(&state, 10, None, None).await.unwrap(); - assert_eq!(feed.items.len(), 1); + // Three seeded things: the model-rich parametric kit plus two cover-less + // demo things that exercise the missing-media placeholder path. + assert_eq!(feed.items.len(), 3); + // feed_recent is rkey DESC, so "parametric-enclosure" sorts first. assert_eq!( feed.items[0].thing.name.as_str(), "Parametric enclosure kit" ); assert_eq!(feed.items[0].thing.model_count, 3); assert_eq!(feed.items[0].thing.part_count, 18); + let names: Vec<&str> = feed + .items + .iter() + .map(|item| item.thing.name.as_str()) + .collect(); + assert!(names.contains(&"Untitled thing")); + assert!(names.contains(&"Modular calibration tower")); let (thing, models) = views::get_thing(&state, SAMPLE_THING_URI, None) .await diff --git a/src/main.rs b/src/main.rs index a06c372..2736f30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,8 @@ mod fonts; mod foundation; mod mesh; mod profile; +mod publish; +mod search; mod session; mod shell; mod thing_card; @@ -31,18 +33,23 @@ use browse::Browse; use client::PolymodelClient; use foundation::Foundation; use profile::{Profile, ProfileSelf}; +use publish::Publish; +use search::Search; use session::SessionIdentity; -use shell::ToastShelf; +use shell::{AppShell, ToastShelf}; use thing_detail::ThingDetail; use viewer_route::Viewer; const FAVICON: Asset = asset!("/assets/favicon.jpg"); const THEME_CSS: Asset = asset!("/assets/styling/theme.css"); const BASE_CSS: Asset = asset!("/assets/styling/base.css"); -const CARDS_CSS: Asset = asset!("/assets/styling/cards.css"); -const BROWSE_CSS: Asset = asset!("/assets/styling/browse.css"); -const POLYMODEL_CSS: Asset = asset!("/assets/styling/polymodel.css"); +const PRIMITIVES_CSS: Asset = asset!("/assets/styling/primitives.css"); +const SHELL_CSS: Asset = asset!("/assets/styling/shell.css"); +const HOME_CSS: Asset = asset!("/assets/styling/home.css"); +const THING_DETAIL_CSS: Asset = asset!("/assets/styling/thing-detail.css"); const PROFILE_CSS: Asset = asset!("/assets/styling/profile.css"); +const VIEWER_CSS: Asset = asset!("/assets/styling/viewer.css"); +const PLACEHOLDERS_CSS: Asset = asset!("/assets/styling/placeholders.css"); #[cfg(feature = "server")] fn main() { @@ -68,9 +75,11 @@ fn main() { .await .expect("failed to seed local/demo sample data"); } - indexing::start_indexing(db.clone()) - .await - .expect("failed to start indexing pipeline"); + // Indexing is a soft dependency; serve read-only from the projection if + // the firehose can't start (e.g. the hydrant DB is locked by another dev server). + if let Err(e) = indexing::start_indexing(db.clone()).await { + tracing::error!("indexing pipeline disabled, serving from existing projection: {e:#}"); + } let oauth = oauth::bootstrap_oauth(db.clone(), cfg.base_url.as_deref()) .expect("failed to bootstrap OAuth client"); // The same SQLite pool backs both indexing and the appview read handlers. @@ -102,10 +111,15 @@ fn main() { #[derive(Clone, Debug, PartialEq, Routable)] pub(crate) enum Route { + #[layout(AppShell)] #[route("/")] Browse {}, #[route("/foundation")] Foundation {}, + #[route("/search?:q")] + Search { q: String }, + #[route("/publish")] + Publish {}, #[route("/profile")] ProfileSelf {}, #[route("/profile/:actor")] @@ -142,10 +156,13 @@ fn App() -> Element { document::Link { rel: "icon", href: FAVICON } Stylesheet { href: THEME_CSS } Stylesheet { href: BASE_CSS } - Stylesheet { href: CARDS_CSS } - Stylesheet { href: BROWSE_CSS } - Stylesheet { href: POLYMODEL_CSS } + Stylesheet { href: PRIMITIVES_CSS } + Stylesheet { href: SHELL_CSS } + Stylesheet { href: HOME_CSS } + Stylesheet { href: THING_DETAIL_CSS } Stylesheet { href: PROFILE_CSS } + Stylesheet { href: VIEWER_CSS } + Stylesheet { href: PLACEHOLDERS_CSS } Router:: {} ToastShelf { toasts } diff --git a/src/profile.rs b/src/profile.rs index 8e13b06..e3bad29 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -230,6 +230,9 @@ pub(crate) fn Profile(actor: String) -> Element { pub(crate) fn ProfileSelf() -> Element { let client = use_context::(); let session = use_context::>(); + // Sign-in must return the user to the page they started on; here that is + // the current profile route rather than a hardcoded path. + let return_to = use_route::().to_string(); // Re-check the session on mount — the App-level seed runs once and may have // failed or not resolved yet. This ensures /profile always has a fresh read. let session_check = use_resource(move || { @@ -256,10 +259,8 @@ pub(crate) fn ProfileSelf() -> Element { if session_check.pending() { return rsx! { main { class: "profile-page product-shell", - nav { class: "profile-nav", aria_label: "Profile navigation", - Link { to: Route::Browse {}, "← Back to browse" } - } section { class: "profile-shell blueprint-panel", + h1 { class: "sr-only", "Loading profile" } div { class: "state-card loading-state", span { class: "status-pill", "Loading" } div { class: "skeleton-line skeleton-wide" } @@ -277,9 +278,6 @@ pub(crate) fn ProfileSelf() -> Element { } SessionIdentity::Anonymous => rsx! { main { class: "profile-page product-shell", - nav { class: "profile-nav", aria_label: "Profile navigation", - Link { to: Route::Browse {}, "← Back to browse" } - } section { class: "profile-sign-in blueprint-panel", span { class: "status-pill", "Signed out" } h1 { "Your profile" } @@ -288,7 +286,7 @@ pub(crate) fn ProfileSelf() -> Element { class: "session-control", action: "/oauth/start", method: "get", - input { r#type: "hidden", name: "return_to", value: "/profile" }, + input { r#type: "hidden", name: "return_to", value: "{return_to}" }, input { r#type: "text", name: "identifier", @@ -321,9 +319,6 @@ fn ProfileBody(actor: String) -> Element { rsx! { main { class: "profile-page product-shell", - nav { class: "profile-nav", aria_label: "Profile navigation", - Link { to: Route::Browse {}, "← Back to browse" } - } match state { ProfileState::Loading => rsx! { ProfileLoading {} }, ProfileState::InvalidActor(message) => rsx! { @@ -370,6 +365,7 @@ fn ProfileBody(actor: String) -> Element { fn ProfileLoading() -> Element { rsx! { section { class: "profile-shell blueprint-panel", aria_label: "Profile loading", + h1 { class: "sr-only", "Loading profile" } div { class: "state-card loading-state", span { class: "status-pill", "Loading" } div { class: "skeleton-line skeleton-wide" } @@ -440,6 +436,7 @@ fn ProfilePopulated( active_tab: Signal, ) -> Element { let header = data.header; + let return_to = use_route::().to_string(); let things = data.things; let display_label = header_display_label(&header); let handle_str = header.handle.as_ref().to_string(); @@ -502,7 +499,7 @@ fn ProfilePopulated( button { class: "button button-secondary", disabled: true, "Edit profile" } } else if is_anonymous { form { class: "profile-follow-form", action: "/oauth/start", method: "get", - input { r#type: "hidden", name: "return_to", value: "/profile/{handle_str}" } + input { r#type: "hidden", name: "return_to", value: "{return_to}" } button { class: "button button-primary", r#type: "submit", "Sign in to follow" } } } else { diff --git a/src/publish.rs b/src/publish.rs new file mode 100644 index 0000000..ecd4dd6 --- /dev/null +++ b/src/publish.rs @@ -0,0 +1,55 @@ +use dioxus::prelude::*; + +use crate::Route; +use crate::session::SessionIdentity; + +/// Honest placeholder for the publish flow. The wizard (PM-43) is not built +/// yet, so this route reserves the surface and prompts anonymous users to sign +/// in with a `return_to` of `/publish` — never a faked upload form. +#[allow(clippy::useless_format)] +#[component] +pub(crate) fn Publish() -> Element { + let session = use_context::>(); + let return_to = use_route::().to_string(); + let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous); + + rsx! { + main { class: "publish-page", + section { class: "publish-intro blueprint-panel", aria_label: "Publish", + span { class: "status-pill status-muted", "Coming soon" } + h1 { "Publish a thing" } + 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." } + } + + if is_anonymous { + section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in to publish", + span { class: "status-pill", "Signed out" } + h2 { "Sign in to publish" } + p { "Publishing needs an ATProto account. Sign in now and you return here, ready for when the flow ships." } + form { + class: "session-control", + action: "/oauth/start", + method: "get", + input { r#type: "hidden", name: "return_to", value: "{return_to}" }, + input { + r#type: "text", + name: "identifier", + placeholder: "handle.test", + aria_label: "ATProto handle for publishing", + } + button { class: "button button-primary", r#type: "submit", "Sign in" } + } + } + } else { + section { class: "publish-steps blueprint-panel", aria_label: "Publish outline", + h2 { "What the flow will cover" } + ol { class: "instruction-list", + li { "Describe the thing: title, summary, tags, and license." } + li { "Add models and upload their ordered parts and files." } + li { "Review, then publish to your ATProto repository." } + } + } + } + } + } +} diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..03d9e50 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,28 @@ +use dioxus::prelude::*; + +use crate::Route; + +/// Honest placeholder for directed catalog search. The global shell search +/// field routes here with `?q=`. Search indexing and filtering are not built +/// yet, so this page captures the query without ever fabricating results. +#[allow(clippy::useless_format)] +#[component] +pub(crate) fn Search(q: String) -> Element { + let query = q.trim().to_string(); + + rsx! { + main { class: "search-page", + section { class: "search-intro blueprint-panel", aria_label: "Search", + span { class: "status-pill status-muted", "Not wired up yet" } + h1 { "Search" } + if query.is_empty() { + p { class: "product-lede", "Directed catalog search is on the way. Run a query from the header and it will be captured here." } + } else { + 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." } + } + p { class: "search-note", "Until search ships, browse the catalog from the home feed." } + Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } + } + } + } +} diff --git a/src/shell.rs b/src/shell.rs index 1f7edbb..bad1f39 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -4,6 +4,85 @@ use crate::Route; use crate::auth::{ToastState, broadcast_sign_out, logout_url}; use crate::session::SessionIdentity; +/// Global product shell. Renders the persistent header (wordmark, primary nav, +/// global directed-search field, Publish CTA, and session/account control) once +/// and hosts every route through the router `Outlet`. Pages render their own +/// `
`/`

` inside the outlet, so the shell deliberately uses a `
` +/// content region rather than a competing `
`. +#[component] +pub(crate) fn AppShell() -> Element { + let session = use_context::>(); + let toasts = use_context::>(); + + rsx! { + div { class: "app-shell", + header { class: "app-header", + div { class: "app-header-inner", + Link { + class: "app-wordmark", + to: Route::Browse {}, + aria_label: "Polymodel home", + "Polymodel" + } + nav { class: "app-nav", aria_label: "Primary", + Link { class: "app-nav-link", active_class: "is-active", to: Route::Browse {}, "Browse" } + } + ShellSearch {} + Link { class: "button button-primary app-publish-cta", to: Route::Publish {}, "Publish" } + SessionControl { session, toasts } + } + } + div { class: "app-content", + Outlet:: {} + } + } + } +} + +/// Global directed-search field. Submits client-side through the navigator to +/// the `/search` route; it never performs a full document navigation (contrast +/// `SessionControl`, which issues a real GET to `/oauth/start`). +#[component] +fn ShellSearch() -> Element { + let mut query = use_signal(String::new); + + rsx! { + div { class: "app-search", role: "search", + input { + class: "app-search-input", + r#type: "search", + name: "q", + placeholder: "Search the catalog", + aria_label: "Search Polymodel", + value: "{query}", + oninput: move |event| query.set(event.value()), + onkeydown: move |event| { + if event.key() == Key::Enter { + event.prevent_default(); + let q = query.read().trim().to_string(); + navigator().push(Route::Search { q }); + } + }, + } + button { + class: "button button-secondary app-search-button", + r#type: "button", + onclick: move |_| { + let q = query.read().trim().to_string(); + navigator().push(Route::Search { q }); + }, + "Search" + } + } + } +} + +/// Returns the current route as a path string for use as an OAuth `return_to` +/// value, so signing in returns the user to the page they started on. +fn current_return_to() -> String { + use_route::().to_string() +} + #[allow(clippy::useless_format)] #[component] pub(crate) fn SessionControl( @@ -11,6 +90,7 @@ pub(crate) fn SessionControl( toasts: Signal, ) -> Element { let identity = session.read().clone(); + let return_to = current_return_to(); match identity { SessionIdentity::Anonymous => rsx! { @@ -18,7 +98,7 @@ pub(crate) fn SessionControl( class: "session-control", action: "/oauth/start", method: "get", - input { r#type: "hidden", name: "return_to", value: "/" }, + input { r#type: "hidden", name: "return_to", value: "{return_to}" }, input { r#type: "text", name: "identifier", diff --git a/src/thing_detail.rs b/src/thing_detail.rs index 52bc872..b36c82d 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -179,9 +179,6 @@ pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { rsx! { main { class: "thing-detail-page product-shell", - nav { class: "detail-nav", aria_label: "Thing detail navigation", - Link { to: Route::Browse {}, "← Back to browse" } - } match state { DetailState::Loading => rsx! { DetailLoading {} }, DetailState::InvalidRoute(message) => rsx! { DetailError { title: "Thing route unavailable".to_string(), message } }, @@ -197,6 +194,7 @@ pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { fn DetailLoading() -> Element { rsx! { section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing detail loading", + h1 { class: "sr-only", "Loading thing" } div { class: "state-card loading-state", span { class: "status-pill", "Loading" } div { class: "skeleton-line skeleton-wide" } diff --git a/src/viewer_route.rs b/src/viewer_route.rs index f1b13a7..c188d87 100644 --- a/src/viewer_route.rs +++ b/src/viewer_route.rs @@ -1,6 +1,5 @@ use dioxus::prelude::*; -use crate::Route; use crate::viewer::{ViewerPage, demo_model_for_route}; #[component] @@ -9,9 +8,6 @@ pub(crate) fn Viewer(model_id: String) -> Element { rsx! { main { class: "viewer-route-page", - nav { class: "viewer-route-nav", aria_label: "Viewer navigation", - Link { to: Route::Browse {}, "← Back to browse" } - } ViewerPage { key: "{model.id}", initial_model: model } } } -- knotmirror 0.0.1