diff --git a/.gitignore b/.gitignore index 0315cbf..c5a483d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .env.* !.envrc !.polytoken +.playwright-mcp node_modules/ .polytoken/permissions.local.yaml /e2e/test-results/** @@ -15,4 +16,4 @@ node_modules/ /src/env.rs # Local databases (SQLite projection + Hydrant fjall store). /data/ -/hydrant.db/ \ No newline at end of file +/hydrant.db/ diff --git a/AGENTS.md b/AGENTS.md index 779dea1..4434b7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,16 @@ Before review or handoff, run at least `just fix` and the narrowest relevant tes - Dioxus 0.7 is the UI framework. - Jacquard 0.12 is the AT Protocol client stack. - The project uses Rust 2024 on nightly with `wasm32-unknown-unknown` available. -- for e2e tests, run `npm install` in the `./e2e` directory if it's the first time in the worktree, then run e2e tests. +- For e2e tests, run `npm install` in the `./e2e` directory if it's the first time in the worktree, then run e2e tests. + +### Front-end implementation notes + +- Dioxus asset declarations use source paths, but served asset URLs are flattened into `/assets/`. For example, declare a font with `asset!("/assets/fonts/proza-libre/ProzaLibre-Regular.woff2", ...)`, but reference it from CSS as `url("/assets/ProzaLibre-Regular.woff2")`. Verify font and model asset paths in browser network output when changing asset declarations. +- Keep generated lexicon/API code out of routine formatting churn. `crates/polymodel-api` is generated and is ignored by `rustfmt.toml`; do not include generated reformatting in ordinary feature slices unless the task is explicitly about regeneration or codegen output. +- Do not commit Playwright MCP scratch output. `.playwright-mcp/` contains local browser logs/snapshots from manual checks and should stay ignored/untracked. +- 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. ### Lexicons & codegen diff --git a/Cargo.lock b/Cargo.lock index efdfb9e..3ee73f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6083,6 +6083,7 @@ dependencies = [ "jacquard-derive 0.12.0", "js-sys", "libsqlite3-sys", + "rustls", "serde", "serde_json", "sqlx", diff --git a/Cargo.toml b/Cargo.toml index 076f07b..fd4c808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,8 @@ server = [ "dep:dotenvy", "dep:chrono", "dep:tracing-subscriber", + "dep:rustls", ] - [dependencies] dioxus = { version = "0.7", features = ["router", "fullstack"] } jacquard = { workspace = true } @@ -56,10 +56,10 @@ axum = { version = "0.8", optional = true } tower = { version = "0.5", optional = true } tower-http = { version = "0.6", optional = true, features = ["trace"] } three-d = { version = "0.19.0", default-features = false } - +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"], optional = true } # Server-only indexing infrastructure. # hydrant is pinned to a specific commit to protect against breaking API changes. -hydrant = { git = "https://tangled.org/ptr.pet/hydrant", rev = "1145fa1bb194372ceea81ab48b5d68d304da34ad", optional = true } +hydrant = { git = "https://tangled.org/ptr.pet/hydrant", rev = "1145fa1bb194372ceea81ab48b5d68d304da34ad", default-features = false, features = ["indexer", "indexer_stream"], optional = true } # Compile-time-checked queries. sqlx version matches the `sqlx-cli` provided by # the nix dev shell so `cargo sqlx prepare` and the macros agree on the .sqlx # cache format. `cargo sqlx prepare --features server` generates the committed diff --git a/assets/styling/browse.css b/assets/styling/browse.css index 241b573..65bdd5e 100644 --- a/assets/styling/browse.css +++ b/assets/styling/browse.css @@ -1,92 +1,175 @@ -/* Browse page — grid layout and filter bar */ +/* Browse foundation layout. */ .browse-page { - padding: 1.5rem 3rem; - max-width: 960px; + min-height: 100vh; + max-width: 1180px; margin: 0 auto; + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); } -.browse-header { - margin-bottom: 1rem; +.product-shell { + display: flex; + flex-direction: column; + gap: 0.9rem; } -.browse-header h1 { - font-family: var(--font-heading, var(--font-ui)); +.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; - color: var(--color-text); - margin: 0 0 0.75rem 0; + padding: 0.45rem 0; } -.browse-filter { - width: 100%; - padding: 0.5rem 0.75rem; - font-family: var(--font-mono); - font-size: 0.9rem; - background-color: var(--color-surface); - color: var(--color-text); +.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); } -.browse-filter:focus { - outline: none; - border-color: var(--color-primary); +.blueprint-preview::before, +.blueprint-preview::after { + content: ""; + position: absolute; + inset: 18% 16%; + border: 1px solid var(--color-primary); } -.browse-filter::placeholder { - color: var(--color-muted); +.blueprint-preview::after { + inset: 32% 26%; + border-color: var(--color-action); } -.browse-grid { +.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: repeat(auto-fill, minmax(240px, 1fr)); - gap: 1.5rem; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: end; + padding: 1rem; } -.browse-empty { - grid-column: 1 / -1; - text-align: center; - padding: 3rem 1rem; - color: var(--color-muted); +.foundation-grid, +.state-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + scroll-margin-top: 1rem; } -.browse-error { - grid-column: 1 / -1; - text-align: center; - padding: 3rem 1rem; - color: var(--color-error); +.foundation-card { + display: grid; + grid-template-rows: auto 1fr; + min-width: 0; + overflow: hidden; } -.browse-error button { - margin-top: 0.75rem; - padding: 0.5rem 1rem; - background-color: var(--color-error); - color: var(--color-base); - border: 1px solid var(--color-error); - cursor: pointer; - font-family: var(--font-ui); +.state-card { + display: grid; + align-content: start; + gap: 0.75rem; + min-height: 11rem; + padding: 1rem; } -.browse-error button:hover { - opacity: 0.85; +.state-card p { + margin: 0; + color: var(--color-subtle); } -/* Mobile adjustments */ +.error-state { + border-color: color-mix(in srgb, var(--color-error) 55%, var(--color-border)); +} -@media (max-width: 768px) { - .browse-page { - padding: 0.75rem; +@media (max-width: 820px) { + .foundation-hero, + .browse-controls { + grid-template-columns: 1fr; } - .browse-header h1 { - font-size: 1.25rem; + .product-header-row { + display: grid; + align-items: start; } - .browse-filter { - font-size: 1rem; - padding: 0.75rem; + .product-nav { + justify-content: flex-start; } - .browse-grid { - gap: 0.75rem; + .foundation-grid, + .state-grid { + grid-template-columns: 1fr; } } diff --git a/assets/styling/cards.css b/assets/styling/cards.css index 47da534..e9ec8d7 100644 --- a/assets/styling/cards.css +++ b/assets/styling/cards.css @@ -1,262 +1,198 @@ -/* Video card styles — adapted from weaver.sh cards-base.css */ +/* Reusable card, button, input, chip, and state primitives. */ -.card-surface { +.card-surface, +.blueprint-panel, +.state-card { background: var(--color-surface); - box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 6%, transparent); -} - -@media (prefers-color-scheme: dark) { - .card-surface { - box-shadow: none; - border-block: 1px solid var(--color-border); - border-inline-end: 1px solid var(--color-border); - } + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + box-shadow: 0 1px 0 color-mix(in srgb, var(--color-text) 8%, transparent); } .card-title { - font-family: var(--font-ui); + font-family: var(--font-heading); font-weight: 600; color: var(--color-text); margin: 0; - font-size: 0.95rem; - transition: color 0.2s ease; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - line-clamp: 3; + font-size: 1rem; + line-height: 1.3; } -.card-title-long { - font-size: 0.85rem; +.card-body { + padding: 1rem; + display: grid; + gap: 0.65rem; } -.card-title-sub { - display: block; - font-size: 0.82rem; - font-weight: 400; +.card-body p { + margin: 0; color: var(--color-subtle); - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - line-clamp: 2; + font-size: 0.92rem; } .card-meta { color: var(--color-muted); - font-size: 0.85rem; + font-family: var(--font-mono); + font-size: 0.74rem; } -.video-card { +.card-media, +.blueprint-media, +.blueprint-preview { position: relative; - display: flex; - flex-direction: column; - color: inherit; - border-inline-start: 1px solid var(--color-border); - transition: border-color 0.15s ease; overflow: hidden; - min-width: 0; + background: + linear-gradient(var(--color-grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px), + var(--color-overlay); + background-size: 20px 20px; } -.video-card:hover { - border-inline-start: 3px solid var(--color-primary); - margin-inline-start: -2px; -} - -.video-card:hover .card-title { - color: var(--color-tertiary); -} - -.video-card-link { - position: absolute; - inset: 0; - z-index: 0; - text-decoration: none; +.blueprint-media { + min-height: 8rem; + padding: 0.75rem; } -.video-card-thumbnail { - position: relative; - width: 100%; - aspect-ratio: 16 / 9; - overflow: hidden; - background: var(--color-overlay); - display: flex; +.button, +.viewer-route-nav a { + display: inline-flex; align-items: center; justify-content: center; + gap: 0.35rem; + width: fit-content; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + padding: 0.55rem 0.8rem; + color: var(--color-text); + background: var(--color-surface); + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 600; + line-height: 1; + text-decoration: none; } -.video-card-thumbnail > img { - width: 100%; - height: 100%; - object-fit: cover; +.button:hover, +.viewer-route-nav a:hover { + border-color: var(--color-primary); + color: var(--color-tertiary); + text-decoration: none; } -.video-card-placeholder { - color: var(--color-subtle); - font-size: 2rem; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - background: var(--color-overlay); +.button-primary { + border-color: var(--color-action); + background: var(--color-action); + color: var(--color-base); } -.video-card-body { - position: relative; - z-index: 1; - pointer-events: none; - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.35rem; - flex: 1; +.button-primary:hover { + filter: brightness(1.04); + color: var(--color-base); } -.video-card-byline { - margin-top: auto; - display: flex; - flex-direction: column; - gap: 0.15rem; +.button-secondary { + border-color: var(--color-primary); + color: var(--color-tertiary); } -.video-card-footer { - margin-top: auto; - display: flex; - flex-direction: column; - gap: 0.35rem; +.button-ghost { + background: transparent; } -.video-card-byline-time { +.button-row, +.tab-row, +.product-nav { display: flex; - justify-content: space-between; + flex-wrap: wrap; gap: 0.5rem; } -.video-card-displayname { - color: var(--color-text); -} - -.video-card-handle { - color: var(--color-muted); - font-size: 0.8rem; +.search-field { + display: grid; + gap: 0.35rem; + color: var(--color-subtle); + font-size: 0.85rem; } -/* Thumbnail overlays */ - -.video-card-tag { - position: absolute; - top: 0.4rem; - left: 0.4rem; - font-size: 0.7rem; - background: rgba(0, 0, 0, 0.7); - color: #fff; - padding: 0.15rem 0.4rem; - border-radius: 3px; - white-space: nowrap; +.search-field span, +.eyebrow, +.product-kicker { + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; } -.video-card-duration-badge { - position: absolute; - bottom: 0.4rem; - right: 0.4rem; - font-family: var(--font-mono); - font-size: 0.75rem; - background: rgba(0, 0, 0, 0.75); - color: #fff; - padding: 0.1rem 0.35rem; - border-radius: 3px; +.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); + color: var(--color-text); + font-family: var(--font-ui); } -.video-card-thumb-author { - position: absolute; - bottom: 0.4rem; - left: 0.4rem; - display: flex; - flex-direction: column; - gap: 0.25rem; - z-index: 1; +.search-field input:focus { + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + border-color: var(--color-primary); } -.video-card-thumb-name { +.tab, +.status-pill { display: inline-flex; align-items: center; - gap: 0.35rem; - background: rgba(0, 0, 0, 0.8); - padding: 0.3rem 0.45rem; - border-radius: 3px; - max-width: 100%; - overflow: hidden; - text-decoration: none; - color: inherit; - transition: background 0.15s ease; + width: fit-content; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + background: transparent; + color: var(--color-subtle); + font-family: var(--font-mono); + font-size: 0.72rem; + line-height: 1; + padding: 0.35rem 0.5rem; } -.video-card-thumb-name:hover { - background: rgba(0, 0, 0, 0.95); +.tab.is-active, +.status-pill { + border-color: var(--color-primary); + color: var(--color-tertiary); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); } -.video-card-thumb-author-info { - display: flex; - flex-direction: column; - gap: 0; - min-width: 0; +.status-muted { + border-color: var(--color-muted); + color: var(--color-muted); + background: transparent; } -.video-card-thumb-displayname { - font-weight: 600; - font-size: 0.7rem; - color: #fff; - line-height: 1.2; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.status-error { + border-color: var(--color-error); + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 8%, transparent); } -.video-card-thumb-handle { - color: rgba(255, 255, 255, 0.6); - font-family: var(--font-mono); - font-size: 0.6rem; - line-height: 1.2; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.skeleton-line, +.skeleton-box { + background: linear-gradient(90deg, var(--color-overlay), var(--color-highlight), var(--color-overlay)); + background-size: 200% 100%; + animation: blueprint-skeleton 1.8s linear infinite; } -.video-card-thumb-avatar { - width: 20px; - height: 20px; - border-radius: 3px; - flex-shrink: 0; +.skeleton-line { + height: 0.7rem; + width: 65%; } -.video-card-tags { - display: flex; - flex-wrap: wrap; - gap: 0.3rem; -} - -.video-card-chip { - font-family: var(--font-mono); - font-size: 0.6rem; - color: var(--color-subtle); - border: 1px solid var(--color-border); - padding: 0.1rem 0.4rem; - white-space: nowrap; - cursor: pointer; - pointer-events: auto; +.skeleton-wide { + width: 90%; } -.video-card-chip:hover { - color: var(--color-text); - border-color: var(--color-subtle); +.skeleton-box { + min-height: 5rem; } -.video-card-datetime, -.video-card-duration { - font-family: var(--font-mono); - font-size: 0.8rem; - color: var(--color-subtle); - white-space: nowrap; +@keyframes blueprint-skeleton { + from { background-position: 200% 0; } + to { background-position: -200% 0; } } diff --git a/assets/styling/polymodel.css b/assets/styling/polymodel.css index a3ee54b..f1842fe 100644 --- a/assets/styling/polymodel.css +++ b/assets/styling/polymodel.css @@ -1,69 +1,7 @@ -.demo-card { - min-height: 11rem; -} - -.demo-card .video-card-thumbnail { - aspect-ratio: 2.4 / 1; - align-items: flex-start; - justify-content: flex-start; - padding: 0.75rem; -} - -.demo-card-token { - font-family: var(--font-mono); - font-size: 0.75rem; - color: var(--color-base); - background: var(--color-primary); - padding: 0.2rem 0.45rem; -} - -.demo-card-body { - pointer-events: auto; -} - -.demo-card-body p { - margin: 0; - color: var(--color-subtle); - font-size: 0.9rem; - line-height: 1.45; -} - -.demo-card-action, -.viewer-hero-link, -.viewer-route-nav a { - display: inline-flex; - align-items: center; - width: fit-content; - margin-top: 0.85rem; - color: var(--color-base); - background: var(--color-primary); - padding: 0.45rem 0.7rem; - text-decoration: none; - font-weight: 700; -} - -.demo-card-action:hover, -.viewer-hero-link:hover, -.viewer-route-nav a:hover { - filter: brightness(1.05); -} - -.demo-context { - margin: 1rem 0 1.5rem; - padding: 0.75rem 1rem; - border-inline-start: 3px solid var(--color-primary); - background: var(--color-surface); - color: var(--color-subtle); -} - -.demo-context strong { - color: var(--color-text); -} - .viewer-route-page { min-height: 100vh; padding: clamp(1rem, 4vw, 3rem); - background: var(--color-background); + background: var(--color-base); color: var(--color-text); } @@ -125,7 +63,7 @@ .viewer-demo-button { border: 1px solid var(--color-border); - background: var(--color-background); + background: var(--color-base); color: var(--color-text); padding: 0.45rem 0.65rem; cursor: pointer; @@ -145,30 +83,51 @@ .model-viewer-stage { position: relative; - min-height: min(72vh, 42rem); display: grid; grid-template-rows: minmax(24rem, min(56vh, 34rem)) auto; + min-height: min(72vh, 42rem); + 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%), - var(--color-background); + var(--color-base); } .model-viewer-canvas { display: block; width: 100%; - min-height: 0; height: 100%; grid-row: 1; grid-column: 1; + min-height: 0; + height: 100%; + grid-row: 1; + grid-column: 1; touch-action: none; cursor: grab; } -.model-viewer-preview { grid-row: 1; grid-column: 1; align-self: stretch; justify-self: stretch; width: 100%; height: 100%; padding: 2rem; box-sizing: border-box; } +.model-viewer-preview { + grid-row: 1; + grid-column: 1; + align-self: stretch; + justify-self: stretch; + width: 100%; + height: 100%; + padding: 2rem; + box-sizing: border-box; +} + .model-preview-shadow { fill: rgb(0 0 0 / 0.22); } .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-background) 80%, transparent); stroke: var(--color-border); stroke-width: 3; } +.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; } .model-preview-label { fill: var(--color-muted); font-family: var(--font-mono); font-size: 18px; } + .model-viewer-overlay { position: relative; - margin: 1rem; max-width: none; padding: 1rem; border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-surface) 92%, transparent); } + margin: 1rem; + max-width: none; + padding: 1rem; + border: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-surface) 92%, transparent); +} .model-viewer-overlay h2, .model-viewer-overlay p, diff --git a/assets/styling/theme.css b/assets/styling/theme.css index 556239d..36c40a9 100644 --- a/assets/styling/theme.css +++ b/assets/styling/theme.css @@ -1,117 +1,105 @@ -/* Rose Pine theme variables ported for Polymodel - Light: Rose Pine Dawn (moderate contrast variant from weaver.sh) - Dark: Rose Pine (original) via prefers-color-scheme */ +/* Polymodel blueprint foundation tokens. */ -/* --- Ioskeley Mono (monospace) --- */ @font-face { - font-family: "Ioskeley Mono"; - font-style: normal; - font-weight: normal; - src: url("/assets/IoskeleyMono-Regular.woff2") format("woff2"); -} -@font-face { - font-family: "Ioskeley Mono"; + font-family: "Proza Libre"; font-style: normal; - font-weight: lighter; - src: url("/assets/IoskeleyMono-Light.woff2") format("woff2"); + font-weight: 400; + src: url("/assets/ProzaLibre-Regular.woff2") format("woff2"); } + @font-face { - font-family: "Ioskeley Mono"; + font-family: "Proza Libre"; font-style: italic; - font-weight: lighter; - src: url("/assets/IoskeleyMono-LightItalic.woff2") format("woff2"); + font-weight: 400; + src: url("/assets/ProzaLibre-Italic.woff2") format("woff2"); } + @font-face { - font-family: "Ioskeley Mono"; + font-family: "Proza Libre"; font-style: normal; - font-weight: bold; - src: url("/assets/IoskeleyMono-Bold.woff2") format("woff2"); -} -@font-face { - font-family: "Ioskeley Mono"; - font-style: italic; - font-weight: normal; - src: url("/assets/IoskeleyMono-Italic.woff2") format("woff2"); -} -@font-face { - font-family: "Ioskeley Mono"; - font-style: italic; - font-weight: bold; - src: url("/assets/IoskeleyMono-BoldItalic.woff2") format("woff2"); + font-weight: 500; + src: url("/assets/ProzaLibre-Medium.woff2") format("woff2"); } -/* --- CM Sans (UI sans-serif) --- */ @font-face { - font-family: "CM Sans"; + font-family: "Proza Libre"; font-style: normal; - font-weight: normal; - src: url("/assets/CMSans-Regular.woff") format("woff"); + font-weight: 600; + src: url("/assets/ProzaLibre-SemiBold.woff2") format("woff2"); } + @font-face { - font-family: "CM Sans"; - font-style: italic; - font-weight: normal; - src: url("/assets/CMSans-Italic.woff") format("woff"); + font-family: "Proza Libre"; + font-style: normal; + font-weight: 700; + src: url("/assets/ProzaLibre-Bold.woff2") format("woff2"); } + @font-face { - font-family: "CM Sans"; + font-family: "Ioskeley Mono"; font-style: normal; - font-weight: bold; - src: url("/assets/CMSans-Bold.woff") format("woff"); + font-weight: 400; + src: url("/assets/IoskeleyMono-Regular.woff2") format("woff2"); } + @font-face { - font-family: "CM Sans"; - font-style: italic; - font-weight: bold; - src: url("/assets/CMSans-BoldItalic.woff") format("woff"); + font-family: "Ioskeley Mono"; + font-style: normal; + font-weight: 700; + src: url("/assets/IoskeleyMono-Bold.woff2") format("woff2"); } -/* --- Light mode: Rose Pine Dawn (moderate contrast) --- */ :root { - --color-base: #faf4ed; - --color-surface: #fffaf3; - --color-overlay: #f2e9e1; - --color-text: #1f1d2e; - --color-muted: #635e74; - --color-subtle: #4a4560; - --color-emphasis: #1e1a2d; - --color-primary: #907aa9; - --color-secondary: #56949f; - --color-tertiary: #286983; - --color-error: #b4637a; - --color-warning: #ea9d34; - --color-success: #286983; - --color-border: #908caa; - --color-link: #d7827e; - --color-highlight: #cecacd; + color-scheme: light dark; + + --color-base: #f5f1e8; + --color-surface: #fffdf7; + --color-overlay: #e8edf0; + --color-text: #17252f; + --color-muted: #6a747b; + --color-subtle: #445461; + --color-emphasis: #0c1f2c; + --color-primary: #1f6d8a; + --color-secondary: #5a8797; + --color-tertiary: #0f4159; + --color-error: #a13f32; + --color-warning: #b36a16; + --color-success: #2d7358; + --color-border: #a9b8bf; + --color-link: #a24d1f; + --color-highlight: #d8e5e9; + --color-grid-line: color-mix(in srgb, var(--color-primary) 16%, transparent); + --color-action: #c5652a; - --font-ui: "DM Sans", system-ui, -apple-system, sans-serif; - --font-heading: "Capriola", sans-serif; + --font-ui: "Proza Libre", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-heading: "Proza Libre", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-mono: "Ioskeley Mono", "IBM Plex Mono", Consolas, monospace; --spacing-base: 16px; - --spacing-line-height: 1.6; + --spacing-line-height: 1.55; --spacing-scale: 1.25; + --radius-panel: 2px; } -/* --- Dark mode: Rose Pine (original) --- */ @media (prefers-color-scheme: dark) { :root { - --color-base: #191724; - --color-surface: #1f1d2e; - --color-overlay: #26233a; - --color-text: #e0def4; - --color-muted: #6e6a86; - --color-subtle: #908caa; - --color-emphasis: #e0def4; - --color-primary: #c4a7e7; - --color-secondary: #3e8fb0; - --color-tertiary: #9ccfd8; - --color-error: #eb6f92; - --color-warning: #f6c177; - --color-success: #31748f; - --color-border: #403d52; - --color-link: #ebbcba; - --color-highlight: #524f67; + --color-base: #07141c; + --color-surface: #0e202a; + --color-overlay: #152d39; + --color-text: #e5edf0; + --color-muted: #8ea1aa; + --color-subtle: #b7c7ce; + --color-emphasis: #f5fbfd; + --color-primary: #75bfd1; + --color-secondary: #4f91a6; + --color-tertiary: #a8dce8; + --color-error: #ef846f; + --color-warning: #e5a852; + --color-success: #7fcaa8; + --color-border: #2a4856; + --color-link: #f0a06a; + --color-highlight: #193847; + --color-grid-line: color-mix(in srgb, var(--color-primary) 22%, transparent); + --color-action: #e38b4b; } } diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts index 0f23484..46b72ca 100644 --- a/e2e/tests/home.spec.ts +++ b/e2e/tests/home.spec.ts @@ -1,9 +1,34 @@ import { expect, test } from '@playwright/test'; -test('home page shows Polymodel product direction', async ({ page }) => { +test('home page shows the product browse shell', async ({ page }) => { await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Polymodel' })).toBeVisible(); - await expect(page.getByText('Thingiverse on atproto')).toBeVisible(); - await expect(page.getByText('Model viewer')).toBeVisible(); - await expect(page.getByText('Polytoken workflow')).toBeVisible(); + await expect(page.getByLabel('Browse shell')).toBeVisible(); + await expect(page.getByLabel('Browse results placeholder')).toBeVisible(); + await expect(page.getByText('Empty')).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(); +}); + +test('foundation route shows PM-38 primitives and reusable states', async ({ page }) => { + await page.goto('/foundation'); + + await expect(page.getByRole('heading', { name: 'Foundation', exact: true })).toBeVisible(); + await expect(page.getByText('Token and asset check')).toBeVisible(); + await expect(page.getByLabel('Foundation primitives')).toBeVisible(); + await expect(page.getByLabel('Reusable states')).toBeVisible(); + await expect(page.getByText('No projects yet')).toBeVisible(); + await expect(page.getByText('Feed unavailable')).toBeVisible(); +}); + +test('home page no longer uses workflow roadmap demo copy as the product surface', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByText('Thingiverse on atproto')).toHaveCount(0); + await expect(page.getByText('Project lexicon')).toHaveCount(0); + await expect(page.getByText('Jira and Confluence')).toHaveCount(0); + await expect(page.getByText('Polytoken workflow')).toHaveCount(0); + await expect(page.getByText('PM-38 foundation')).toHaveCount(0); }); diff --git a/e2e/tests/viewer.spec.ts b/e2e/tests/viewer.spec.ts index 8ecc60a..fe5b8d1 100644 --- a/e2e/tests/viewer.spec.ts +++ b/e2e/tests/viewer.spec.ts @@ -20,7 +20,7 @@ async function expectViewerReady(page) { test('viewer is reachable from browse and displays the committed STL asset', async ({ page }) => { await page.goto('/'); - await page.locator('header').getByRole('link', { name: 'Open viewer' }).click(); + await page.getByRole('link', { name: 'Open viewer' }).click(); await expect(page).toHaveURL(/\/viewer\/body-f-chest-v4$/); await expectViewerReady(page); diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..96fd0bb --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +# crates/polymodel-api is generated by jacquard; do not make PM slices carry rustfmt churn there. +ignore = ["crates/polymodel-api"] diff --git a/src/fonts.rs b/src/fonts.rs new file mode 100644 index 0000000..cae3574 --- /dev/null +++ b/src/fonts.rs @@ -0,0 +1,62 @@ +use dioxus::prelude::*; + +// Self-hosted font assets used by assets/styling/theme.css. Hash suffixes are +// disabled so @font-face URLs stay stable across builds. + +// Proza Libre (UI and headings) +#[used] +static _FONT_PROZA_REGULAR: Asset = asset!( + "/assets/fonts/proza-libre/ProzaLibre-Regular.woff2", + AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options() +); + +#[used] +static _FONT_PROZA_ITALIC: Asset = asset!( + "/assets/fonts/proza-libre/ProzaLibre-Italic.woff2", + AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options() +); + +#[used] +static _FONT_PROZA_MEDIUM: Asset = asset!( + "/assets/fonts/proza-libre/ProzaLibre-Medium.woff2", + AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options() +); + +#[used] +static _FONT_PROZA_SEMIBOLD: Asset = asset!( + "/assets/fonts/proza-libre/ProzaLibre-SemiBold.woff2", + AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options() +); + +#[used] +static _FONT_PROZA_BOLD: Asset = asset!( + "/assets/fonts/proza-libre/ProzaLibre-Bold.woff2", + AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options() +); + +// Ioskeley Mono (metadata and technical labels) +#[used] +static _FONT_MONO_REGULAR: Asset = asset!( + "/assets/fonts/ioskeley-mono/IoskeleyMono-Regular.woff2", + AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options() +); + +#[used] +static _FONT_MONO_BOLD: Asset = asset!( + "/assets/fonts/ioskeley-mono/IoskeleyMono-Bold.woff2", + AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options() +); diff --git a/src/main.rs b/src/main.rs index ade0cd4..4b8535b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use dioxus::prelude::*; +mod fonts; mod mesh; mod viewer; @@ -29,7 +30,7 @@ fn main() { let _ = tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .try_init(); - + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); // Hydrant selects AWS-LC while other deps enable ring; choose before websocket TLS setup. let cfg = indexing::config::ServerConfig::load(); let db = indexing::db::init_db(&cfg) .await @@ -57,6 +58,8 @@ fn main() { enum Route { #[route("/")] Browse {}, + #[route("/foundation")] + Foundation {}, #[route("/viewer/:model_id")] Viewer { model_id: String }, } @@ -67,9 +70,6 @@ fn App() -> Element { document::Meta { name: "viewport", content: "width=device-width, initial-scale=1" } document::Title { "Polymodel" } document::Link { rel: "icon", href: FAVICON } - document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" } - document::Link { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "anonymous" } - document::Link { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Capriola&family=DM+Sans:opsz,wght@9..40,300..800&display=swap" } Stylesheet { href: THEME_CSS } Stylesheet { href: BASE_CSS } Stylesheet { href: CARDS_CSS } @@ -82,25 +82,113 @@ fn App() -> Element { #[component] fn Browse() -> Element { + let viewer_model = demo_models()[0].id.to_string(); + rsx! { - main { class: "browse-page", - header { class: "browse-header", - h1 { "Polymodel" } - div { class: "demo-context", - strong { "Thingiverse on atproto. " } - "Project/profile/like lexicons, social feeds, and a browser STL viewer. " - strong { "Workflow: " } - "Jira + Confluence + jj + Polytoken." + main { class: "browse-page product-shell", + header { class: "product-header", + div { class: "product-kicker", "ATProto model sharing" } + 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" + } + } + } + } + + section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", + label { class: "search-field", + span { "Search model projects" } + input { + r#type: "search", + placeholder: "Search by project, tag, or maker", + aria_label: "Search model projects" + } } - Link { - class: "viewer-hero-link", - to: Route::Viewer { model_id: demo_models()[0].id.to_string() }, - "Open viewer" + div { class: "tab-row", role: "tablist", aria_label: "Feed placeholders", + button { class: "tab is-active", role: "tab", aria_selected: "true", "Recent" } + button { class: "tab", role: "tab", aria_selected: "false", "Hot" } + button { class: "tab", role: "tab", aria_selected: "false", "Following" } } } - section { class: "browse-grid", - for card in roadmap_cards() { - RoadmapCard { card } + + section { class: "state-card empty-state", aria_label: "Browse results placeholder", + span { class: "status-pill status-muted", "Empty" } + } + } + } +} + +#[component] +fn Foundation() -> Element { + rsx! { + main { class: "browse-page product-shell", + header { class: "product-header", + div { class: "product-kicker", "PM-38 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" } + } + } + } + + section { id: "browse-foundation", class: "foundation-hero blueprint-panel", + div { class: "foundation-hero-copy", + p { class: "eyebrow", "Token and asset check" } + h2 { "Blueprint styling foundation" } + p { + "This route keeps PM-38 primitives visible without making implementation scaffolding the product home." + } + div { class: "button-row", + button { class: "button button-primary", "Primary action" } + button { class: "button button-ghost", "Ghost action" } + } + } + div { class: "blueprint-preview", aria_label: "Blueprint placeholder preview", + div { class: "blueprint-preview-grid" } + span { class: "blueprint-axis blueprint-axis-x", "X" } + span { class: "blueprint-axis blueprint-axis-y", "Y" } + span { class: "blueprint-dimension", "120 × 86 × 42 mm" } + } + } + + section { class: "foundation-grid", aria_label: "Foundation primitives", + for item in foundation_items() { + FoundationCard { item } + } + } + + section { id: "ui-states", class: "state-grid", aria_label: "Reusable states", + div { class: "state-card loading-state", + span { class: "status-pill", "Loading" } + div { class: "skeleton-line skeleton-wide" } + div { class: "skeleton-line" } + div { class: "skeleton-box" } + } + div { class: "state-card empty-state", + span { class: "status-pill status-muted", "Empty" } + h2 { "No projects yet" } + p { "Empty browse states can use this panel before live feeds are connected." } + } + div { class: "state-card error-state", + span { class: "status-pill status-error", "Error" } + h2 { "Feed unavailable" } + p { "Error states keep the browse surface useful without inventing data plumbing in this slice." } } } } @@ -122,71 +210,47 @@ fn Viewer(model_id: String) -> Element { } #[derive(Clone, Copy, PartialEq)] -struct RoadmapCardData { - token: &'static str, +struct FoundationItem { + label: &'static str, title: &'static str, body: &'static str, - viewer_link: Option<&'static str>, + meta: &'static str, } -fn roadmap_cards() -> [RoadmapCardData; 6] { +fn foundation_items() -> [FoundationItem; 3] { [ - RoadmapCardData { - token: "NSID", - title: "Project lexicon", - body: "Define the model/project record: title, description, files, preview assets, tags, license, and attribution.", - viewer_link: None, - }, - RoadmapCardData { - token: "DID", - title: "Profile publishing", - body: "Use ATProto identity and profile-oriented feeds so makers can publish and browse from their own repos.", - viewer_link: None, + FoundationItem { + label: "Panel", + title: "Project card surface", + body: "A crisp bordered card for future model summaries, previews, and maker metadata.", + meta: "primitive/card", }, - RoadmapCardData { - token: "★", - title: "Likes and saves", - body: "Start with lightweight feedback records that can drive hot/recent discovery without inventing a heavyweight backend.", - viewer_link: None, + FoundationItem { + label: "Input", + title: "Search and filter shell", + body: "Reusable input styling for browse, profile, and publish flows without binding to a data source yet.", + meta: "primitive/input", }, - RoadmapCardData { - token: "STL", - title: "Model viewer", - body: "Spike three-d and kiss3d as WASM viewer candidates; choose based on STL loading, orbit controls, rendering quality, and Dioxus integration friction.", - viewer_link: Some("body-f-chest-v4"), - }, - RoadmapCardData { - token: "PM", - title: "Jira and Confluence", - body: "Use Jira and Confluence as both planning/demo narrative and agent-readable workflow state.", - viewer_link: None, - }, - RoadmapCardData { - token: "pt", - title: "Polytoken workflow", - body: "Use facets, skills, hooks, project vars, jj workspaces, and review gates to make the demo workflow real.", - viewer_link: None, + FoundationItem { + label: "Status", + title: "Metadata and chips", + body: "Compact mono labels for license, dimensions, file state, and feed status.", + meta: "primitive/status", }, ] } #[component] -fn RoadmapCard(card: RoadmapCardData) -> Element { +fn FoundationCard(item: FoundationItem) -> Element { rsx! { - article { class: "video-card card-surface demo-card", - div { class: "video-card-thumbnail", - span { class: "demo-card-token", "{card.token}" } + article { class: "foundation-card card-surface", + div { class: "card-media blueprint-media", + span { class: "status-pill", "{item.label}" } } - div { class: "video-card-body demo-card-body", - h2 { class: "card-title", "{card.title}" } - p { "{card.body}" } - if let Some(model_id) = card.viewer_link { - Link { - class: "demo-card-action", - to: Route::Viewer { model_id: model_id.to_string() }, - "Open viewer" - } - } + div { class: "card-body", + h2 { class: "card-title", "{item.title}" } + p { "{item.body}" } + span { class: "card-meta", "{item.meta}" } } } } @@ -194,19 +258,24 @@ fn RoadmapCard(card: RoadmapCardData) -> Element { #[cfg(test)] mod tests { - use super::roadmap_cards; + use super::foundation_items; #[test] - fn roadmap_includes_stl_viewer() { - assert!(roadmap_cards().iter().any(|card| card.token == "STL")); + fn foundation_items_cover_base_primitives() { + let items = foundation_items(); + assert!(items.iter().any(|item| item.label == "Panel")); + assert!(items.iter().any(|item| item.label == "Input")); + assert!(items.iter().any(|item| item.label == "Status")); } #[test] - fn roadmap_links_to_viewer() { - assert!( - roadmap_cards() - .iter() - .any(|card| card.viewer_link.is_some()) - ); + fn foundation_copy_avoids_workflow_marketing() { + let combined = foundation_items() + .iter() + .map(|item| item.body) + .collect::>() + .join(" "); + assert!(!combined.contains("Jira")); + assert!(!combined.contains("Polytoken")); } }