atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-38: front-end theme foundation and asset serving

Orual (Jun 29, 2026, 7:06 PM EDT) 0b1a0bac 40265690

+624 -489
+2 -1
.gitignore
··· 5 5 .env.* 6 6 !.envrc 7 7 !.polytoken 8 + .playwright-mcp 8 9 node_modules/ 9 10 .polytoken/permissions.local.yaml 10 11 /e2e/test-results/** ··· 15 16 /src/env.rs 16 17 # Local databases (SQLite projection + Hydrant fjall store). 17 18 /data/ 18 - /hydrant.db/ 19 + /hydrant.db/
+10 -1
AGENTS.md
··· 61 61 - Dioxus 0.7 is the UI framework. 62 62 - Jacquard 0.12 is the AT Protocol client stack. 63 63 - The project uses Rust 2024 on nightly with `wasm32-unknown-unknown` available. 64 - - for e2e tests, run `npm install` in the `./e2e` directory if it's the first time in the worktree, then run e2e tests. 64 + - For e2e tests, run `npm install` in the `./e2e` directory if it's the first time in the worktree, then run e2e tests. 65 + 66 + ### Front-end implementation notes 67 + 68 + - 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. 69 + - 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. 70 + - Do not commit Playwright MCP scratch output. `.playwright-mcp/` contains local browser logs/snapshots from manual checks and should stay ignored/untracked. 71 + - 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. 72 + - 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. 73 + - 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. 65 74 66 75 ### Lexicons & codegen 67 76
+1
Cargo.lock
··· 6083 6083 "jacquard-derive 0.12.0", 6084 6084 "js-sys", 6085 6085 "libsqlite3-sys", 6086 + "rustls", 6086 6087 "serde", 6087 6088 "serde_json", 6088 6089 "sqlx",
+3 -3
Cargo.toml
··· 38 38 "dep:dotenvy", 39 39 "dep:chrono", 40 40 "dep:tracing-subscriber", 41 + "dep:rustls", 41 42 ] 42 - 43 43 [dependencies] 44 44 dioxus = { version = "0.7", features = ["router", "fullstack"] } 45 45 jacquard = { workspace = true } ··· 56 56 tower = { version = "0.5", optional = true } 57 57 tower-http = { version = "0.6", optional = true, features = ["trace"] } 58 58 three-d = { version = "0.19.0", default-features = false } 59 - 59 + rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"], optional = true } 60 60 # Server-only indexing infrastructure. 61 61 # hydrant is pinned to a specific commit to protect against breaking API changes. 62 - hydrant = { git = "https://tangled.org/ptr.pet/hydrant", rev = "1145fa1bb194372ceea81ab48b5d68d304da34ad", optional = true } 62 + hydrant = { git = "https://tangled.org/ptr.pet/hydrant", rev = "1145fa1bb194372ceea81ab48b5d68d304da34ad", default-features = false, features = ["indexer", "indexer_stream"], optional = true } 63 63 # Compile-time-checked queries. sqlx version matches the `sqlx-cli` provided by 64 64 # the nix dev shell so `cargo sqlx prepare` and the macros agree on the .sqlx 65 65 # cache format. `cargo sqlx prepare --features server` generates the committed
+138 -55
assets/styling/browse.css
··· 1 - /* Browse page — grid layout and filter bar */ 1 + /* Browse foundation layout. */ 2 2 3 3 .browse-page { 4 - padding: 1.5rem 3rem; 5 - max-width: 960px; 4 + min-height: 100vh; 5 + max-width: 1180px; 6 6 margin: 0 auto; 7 + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 8 + } 9 + 10 + .product-shell { 11 + display: flex; 12 + flex-direction: column; 13 + gap: 0.9rem; 14 + } 15 + 16 + .product-header { 17 + display: grid; 18 + align-content: start; 19 + gap: 0.6rem; 20 + padding-block: 0.25rem 0; 21 + } 22 + 23 + .product-kicker, 24 + .eyebrow { 25 + color: var(--color-primary); 7 26 } 8 27 9 - .browse-header { 10 - margin-bottom: 1rem; 28 + .product-header-row { 29 + display: flex; 30 + justify-content: space-between; 31 + align-items: flex-end; 32 + gap: 1rem; 33 + } 34 + 35 + .product-header h1, 36 + .foundation-hero h2, 37 + .state-card h2 { 38 + margin: 0; 39 + color: var(--color-emphasis); 40 + letter-spacing: -0.03em; 41 + } 42 + 43 + .product-header h1 { 44 + font-size: clamp(2rem, 5vw, 3.75rem); 45 + line-height: 0.95; 46 + } 47 + 48 + .product-lede { 49 + max-width: 44rem; 50 + margin: 0.7rem 0 0; 51 + color: var(--color-subtle); 52 + font-size: clamp(1rem, 2vw, 1.18rem); 11 53 } 12 54 13 - .browse-header h1 { 14 - font-family: var(--font-heading, var(--font-ui)); 55 + .product-nav { 56 + justify-content: flex-end; 57 + align-items: center; 58 + } 59 + 60 + .product-nav a:not(.button) { 61 + color: var(--color-subtle); 15 62 font-weight: 600; 16 - color: var(--color-text); 17 - margin: 0 0 0.75rem 0; 63 + padding: 0.45rem 0; 18 64 } 19 65 20 - .browse-filter { 21 - width: 100%; 22 - padding: 0.5rem 0.75rem; 23 - font-family: var(--font-mono); 24 - font-size: 0.9rem; 25 - background-color: var(--color-surface); 26 - color: var(--color-text); 66 + .foundation-hero { 67 + display: grid; 68 + grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); 69 + gap: clamp(1rem, 3vw, 2rem); 70 + padding: clamp(1rem, 3vw, 1.5rem); 71 + scroll-margin-top: 1rem; 72 + } 73 + 74 + .foundation-hero-copy { 75 + display: grid; 76 + align-content: center; 77 + gap: 0.9rem; 78 + } 79 + 80 + .foundation-hero-copy p { 81 + margin: 0; 82 + color: var(--color-subtle); 83 + max-width: 42rem; 84 + } 85 + 86 + .blueprint-preview { 87 + min-height: 18rem; 27 88 border: 1px solid var(--color-border); 28 89 } 29 90 30 - .browse-filter:focus { 31 - outline: none; 32 - border-color: var(--color-primary); 91 + .blueprint-preview::before, 92 + .blueprint-preview::after { 93 + content: ""; 94 + position: absolute; 95 + inset: 18% 16%; 96 + border: 1px solid var(--color-primary); 33 97 } 34 98 35 - .browse-filter::placeholder { 36 - color: var(--color-muted); 99 + .blueprint-preview::after { 100 + inset: 32% 26%; 101 + border-color: var(--color-action); 37 102 } 38 103 39 - .browse-grid { 104 + .blueprint-axis, 105 + .blueprint-dimension { 106 + position: absolute; 107 + font-family: var(--font-mono); 108 + font-size: 0.72rem; 109 + color: var(--color-primary); 110 + } 111 + 112 + .blueprint-axis-x { right: 0.75rem; bottom: 0.75rem; } 113 + .blueprint-axis-y { left: 0.75rem; top: 0.75rem; } 114 + .blueprint-dimension { left: 0.75rem; bottom: 0.75rem; } 115 + 116 + .browse-controls { 40 117 display: grid; 41 - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); 42 - gap: 1.5rem; 118 + grid-template-columns: minmax(0, 1fr) auto; 119 + gap: 1rem; 120 + align-items: end; 121 + padding: 1rem; 43 122 } 44 123 45 - .browse-empty { 46 - grid-column: 1 / -1; 47 - text-align: center; 48 - padding: 3rem 1rem; 49 - color: var(--color-muted); 124 + .foundation-grid, 125 + .state-grid { 126 + display: grid; 127 + grid-template-columns: repeat(3, minmax(0, 1fr)); 128 + gap: 1rem; 129 + scroll-margin-top: 1rem; 50 130 } 51 131 52 - .browse-error { 53 - grid-column: 1 / -1; 54 - text-align: center; 55 - padding: 3rem 1rem; 56 - color: var(--color-error); 132 + .foundation-card { 133 + display: grid; 134 + grid-template-rows: auto 1fr; 135 + min-width: 0; 136 + overflow: hidden; 57 137 } 58 138 59 - .browse-error button { 60 - margin-top: 0.75rem; 61 - padding: 0.5rem 1rem; 62 - background-color: var(--color-error); 63 - color: var(--color-base); 64 - border: 1px solid var(--color-error); 65 - cursor: pointer; 66 - font-family: var(--font-ui); 139 + .state-card { 140 + display: grid; 141 + align-content: start; 142 + gap: 0.75rem; 143 + min-height: 11rem; 144 + padding: 1rem; 67 145 } 68 146 69 - .browse-error button:hover { 70 - opacity: 0.85; 147 + .state-card p { 148 + margin: 0; 149 + color: var(--color-subtle); 71 150 } 72 151 73 - /* Mobile adjustments */ 152 + .error-state { 153 + border-color: color-mix(in srgb, var(--color-error) 55%, var(--color-border)); 154 + } 74 155 75 - @media (max-width: 768px) { 76 - .browse-page { 77 - padding: 0.75rem; 156 + @media (max-width: 820px) { 157 + .foundation-hero, 158 + .browse-controls { 159 + grid-template-columns: 1fr; 78 160 } 79 161 80 - .browse-header h1 { 81 - font-size: 1.25rem; 162 + .product-header-row { 163 + display: grid; 164 + align-items: start; 82 165 } 83 166 84 - .browse-filter { 85 - font-size: 1rem; 86 - padding: 0.75rem; 167 + .product-nav { 168 + justify-content: flex-start; 87 169 } 88 170 89 - .browse-grid { 90 - gap: 0.75rem; 171 + .foundation-grid, 172 + .state-grid { 173 + grid-template-columns: 1fr; 91 174 } 92 175 }
+128 -192
assets/styling/cards.css
··· 1 - /* Video card styles — adapted from weaver.sh cards-base.css */ 1 + /* Reusable card, button, input, chip, and state primitives. */ 2 2 3 - .card-surface { 3 + .card-surface, 4 + .blueprint-panel, 5 + .state-card { 4 6 background: var(--color-surface); 5 - box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 6%, transparent); 6 - } 7 - 8 - @media (prefers-color-scheme: dark) { 9 - .card-surface { 10 - box-shadow: none; 11 - border-block: 1px solid var(--color-border); 12 - border-inline-end: 1px solid var(--color-border); 13 - } 7 + border: 1px solid var(--color-border); 8 + border-radius: var(--radius-panel); 9 + box-shadow: 0 1px 0 color-mix(in srgb, var(--color-text) 8%, transparent); 14 10 } 15 11 16 12 .card-title { 17 - font-family: var(--font-ui); 13 + font-family: var(--font-heading); 18 14 font-weight: 600; 19 15 color: var(--color-text); 20 16 margin: 0; 21 - font-size: 0.95rem; 22 - transition: color 0.2s ease; 23 - overflow: hidden; 24 - display: -webkit-box; 25 - -webkit-line-clamp: 3; 26 - -webkit-box-orient: vertical; 27 - line-clamp: 3; 17 + font-size: 1rem; 18 + line-height: 1.3; 28 19 } 29 20 30 - .card-title-long { 31 - font-size: 0.85rem; 21 + .card-body { 22 + padding: 1rem; 23 + display: grid; 24 + gap: 0.65rem; 32 25 } 33 26 34 - .card-title-sub { 35 - display: block; 36 - font-size: 0.82rem; 37 - font-weight: 400; 27 + .card-body p { 28 + margin: 0; 38 29 color: var(--color-subtle); 39 - overflow: hidden; 40 - display: -webkit-box; 41 - -webkit-line-clamp: 2; 42 - -webkit-box-orient: vertical; 43 - line-clamp: 2; 30 + font-size: 0.92rem; 44 31 } 45 32 46 33 .card-meta { 47 34 color: var(--color-muted); 48 - font-size: 0.85rem; 35 + font-family: var(--font-mono); 36 + font-size: 0.74rem; 49 37 } 50 38 51 - .video-card { 39 + .card-media, 40 + .blueprint-media, 41 + .blueprint-preview { 52 42 position: relative; 53 - display: flex; 54 - flex-direction: column; 55 - color: inherit; 56 - border-inline-start: 1px solid var(--color-border); 57 - transition: border-color 0.15s ease; 58 43 overflow: hidden; 59 - min-width: 0; 60 - } 61 - 62 - .video-card:hover { 63 - border-inline-start: 3px solid var(--color-primary); 64 - margin-inline-start: -2px; 65 - } 66 - 67 - .video-card:hover .card-title { 68 - color: var(--color-tertiary); 44 + background: 45 + linear-gradient(var(--color-grid-line) 1px, transparent 1px), 46 + linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px), 47 + var(--color-overlay); 48 + background-size: 20px 20px; 69 49 } 70 50 71 - .video-card-link { 72 - position: absolute; 73 - inset: 0; 74 - z-index: 0; 75 - text-decoration: none; 51 + .blueprint-media { 52 + min-height: 8rem; 53 + padding: 0.75rem; 76 54 } 77 55 78 - .video-card-thumbnail { 79 - position: relative; 80 - width: 100%; 81 - aspect-ratio: 16 / 9; 82 - overflow: hidden; 83 - background: var(--color-overlay); 84 - display: flex; 56 + .button, 57 + .viewer-route-nav a { 58 + display: inline-flex; 85 59 align-items: center; 86 60 justify-content: center; 61 + gap: 0.35rem; 62 + width: fit-content; 63 + border: 1px solid var(--color-border); 64 + border-radius: var(--radius-panel); 65 + padding: 0.55rem 0.8rem; 66 + color: var(--color-text); 67 + background: var(--color-surface); 68 + font-family: var(--font-ui); 69 + font-size: 0.9rem; 70 + font-weight: 600; 71 + line-height: 1; 72 + text-decoration: none; 87 73 } 88 74 89 - .video-card-thumbnail > img { 90 - width: 100%; 91 - height: 100%; 92 - object-fit: cover; 75 + .button:hover, 76 + .viewer-route-nav a:hover { 77 + border-color: var(--color-primary); 78 + color: var(--color-tertiary); 79 + text-decoration: none; 93 80 } 94 81 95 - .video-card-placeholder { 96 - color: var(--color-subtle); 97 - font-size: 2rem; 98 - display: flex; 99 - align-items: center; 100 - justify-content: center; 101 - width: 100%; 102 - height: 100%; 103 - background: var(--color-overlay); 82 + .button-primary { 83 + border-color: var(--color-action); 84 + background: var(--color-action); 85 + color: var(--color-base); 104 86 } 105 87 106 - .video-card-body { 107 - position: relative; 108 - z-index: 1; 109 - pointer-events: none; 110 - padding: 0.75rem; 111 - display: flex; 112 - flex-direction: column; 113 - gap: 0.35rem; 114 - flex: 1; 88 + .button-primary:hover { 89 + filter: brightness(1.04); 90 + color: var(--color-base); 115 91 } 116 92 117 - .video-card-byline { 118 - margin-top: auto; 119 - display: flex; 120 - flex-direction: column; 121 - gap: 0.15rem; 93 + .button-secondary { 94 + border-color: var(--color-primary); 95 + color: var(--color-tertiary); 122 96 } 123 97 124 - .video-card-footer { 125 - margin-top: auto; 126 - display: flex; 127 - flex-direction: column; 128 - gap: 0.35rem; 98 + .button-ghost { 99 + background: transparent; 129 100 } 130 101 131 - .video-card-byline-time { 102 + .button-row, 103 + .tab-row, 104 + .product-nav { 132 105 display: flex; 133 - justify-content: space-between; 106 + flex-wrap: wrap; 134 107 gap: 0.5rem; 135 108 } 136 109 137 - .video-card-displayname { 138 - color: var(--color-text); 139 - } 140 - 141 - .video-card-handle { 142 - color: var(--color-muted); 143 - font-size: 0.8rem; 110 + .search-field { 111 + display: grid; 112 + gap: 0.35rem; 113 + color: var(--color-subtle); 114 + font-size: 0.85rem; 144 115 } 145 116 146 - /* Thumbnail overlays */ 147 - 148 - .video-card-tag { 149 - position: absolute; 150 - top: 0.4rem; 151 - left: 0.4rem; 152 - font-size: 0.7rem; 153 - background: rgba(0, 0, 0, 0.7); 154 - color: #fff; 155 - padding: 0.15rem 0.4rem; 156 - border-radius: 3px; 157 - white-space: nowrap; 117 + .search-field span, 118 + .eyebrow, 119 + .product-kicker { 120 + font-family: var(--font-mono); 121 + font-size: 0.72rem; 122 + letter-spacing: 0.08em; 123 + text-transform: uppercase; 158 124 } 159 125 160 - .video-card-duration-badge { 161 - position: absolute; 162 - bottom: 0.4rem; 163 - right: 0.4rem; 164 - font-family: var(--font-mono); 165 - font-size: 0.75rem; 166 - background: rgba(0, 0, 0, 0.75); 167 - color: #fff; 168 - padding: 0.1rem 0.35rem; 169 - border-radius: 3px; 126 + .search-field input { 127 + width: 100%; 128 + padding: 0.75rem 0.85rem; 129 + border: 1px solid var(--color-border); 130 + border-radius: var(--radius-panel); 131 + background: var(--color-base); 132 + color: var(--color-text); 133 + font-family: var(--font-ui); 170 134 } 171 135 172 - .video-card-thumb-author { 173 - position: absolute; 174 - bottom: 0.4rem; 175 - left: 0.4rem; 176 - display: flex; 177 - flex-direction: column; 178 - gap: 0.25rem; 179 - z-index: 1; 136 + .search-field input:focus { 137 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 138 + border-color: var(--color-primary); 180 139 } 181 140 182 - .video-card-thumb-name { 141 + .tab, 142 + .status-pill { 183 143 display: inline-flex; 184 144 align-items: center; 185 - gap: 0.35rem; 186 - background: rgba(0, 0, 0, 0.8); 187 - padding: 0.3rem 0.45rem; 188 - border-radius: 3px; 189 - max-width: 100%; 190 - overflow: hidden; 191 - text-decoration: none; 192 - color: inherit; 193 - transition: background 0.15s ease; 194 - } 195 - 196 - .video-card-thumb-name:hover { 197 - background: rgba(0, 0, 0, 0.95); 145 + width: fit-content; 146 + border: 1px solid var(--color-border); 147 + border-radius: var(--radius-panel); 148 + background: transparent; 149 + color: var(--color-subtle); 150 + font-family: var(--font-mono); 151 + font-size: 0.72rem; 152 + line-height: 1; 153 + padding: 0.35rem 0.5rem; 198 154 } 199 155 200 - .video-card-thumb-author-info { 201 - display: flex; 202 - flex-direction: column; 203 - gap: 0; 204 - min-width: 0; 156 + .tab.is-active, 157 + .status-pill { 158 + border-color: var(--color-primary); 159 + color: var(--color-tertiary); 160 + background: color-mix(in srgb, var(--color-primary) 8%, transparent); 205 161 } 206 162 207 - .video-card-thumb-displayname { 208 - font-weight: 600; 209 - font-size: 0.7rem; 210 - color: #fff; 211 - line-height: 1.2; 212 - overflow: hidden; 213 - text-overflow: ellipsis; 214 - white-space: nowrap; 163 + .status-muted { 164 + border-color: var(--color-muted); 165 + color: var(--color-muted); 166 + background: transparent; 215 167 } 216 168 217 - .video-card-thumb-handle { 218 - color: rgba(255, 255, 255, 0.6); 219 - font-family: var(--font-mono); 220 - font-size: 0.6rem; 221 - line-height: 1.2; 222 - overflow: hidden; 223 - text-overflow: ellipsis; 224 - white-space: nowrap; 169 + .status-error { 170 + border-color: var(--color-error); 171 + color: var(--color-error); 172 + background: color-mix(in srgb, var(--color-error) 8%, transparent); 225 173 } 226 174 227 - .video-card-thumb-avatar { 228 - width: 20px; 229 - height: 20px; 230 - border-radius: 3px; 231 - flex-shrink: 0; 175 + .skeleton-line, 176 + .skeleton-box { 177 + background: linear-gradient(90deg, var(--color-overlay), var(--color-highlight), var(--color-overlay)); 178 + background-size: 200% 100%; 179 + animation: blueprint-skeleton 1.8s linear infinite; 232 180 } 233 181 234 - .video-card-tags { 235 - display: flex; 236 - flex-wrap: wrap; 237 - gap: 0.3rem; 182 + .skeleton-line { 183 + height: 0.7rem; 184 + width: 65%; 238 185 } 239 186 240 - .video-card-chip { 241 - font-family: var(--font-mono); 242 - font-size: 0.6rem; 243 - color: var(--color-subtle); 244 - border: 1px solid var(--color-border); 245 - padding: 0.1rem 0.4rem; 246 - white-space: nowrap; 247 - cursor: pointer; 248 - pointer-events: auto; 187 + .skeleton-wide { 188 + width: 90%; 249 189 } 250 190 251 - .video-card-chip:hover { 252 - color: var(--color-text); 253 - border-color: var(--color-subtle); 191 + .skeleton-box { 192 + min-height: 5rem; 254 193 } 255 194 256 - .video-card-datetime, 257 - .video-card-duration { 258 - font-family: var(--font-mono); 259 - font-size: 0.8rem; 260 - color: var(--color-subtle); 261 - white-space: nowrap; 195 + @keyframes blueprint-skeleton { 196 + from { background-position: 200% 0; } 197 + to { background-position: -200% 0; } 262 198 }
+29 -70
assets/styling/polymodel.css
··· 1 - .demo-card { 2 - min-height: 11rem; 3 - } 4 - 5 - .demo-card .video-card-thumbnail { 6 - aspect-ratio: 2.4 / 1; 7 - align-items: flex-start; 8 - justify-content: flex-start; 9 - padding: 0.75rem; 10 - } 11 - 12 - .demo-card-token { 13 - font-family: var(--font-mono); 14 - font-size: 0.75rem; 15 - color: var(--color-base); 16 - background: var(--color-primary); 17 - padding: 0.2rem 0.45rem; 18 - } 19 - 20 - .demo-card-body { 21 - pointer-events: auto; 22 - } 23 - 24 - .demo-card-body p { 25 - margin: 0; 26 - color: var(--color-subtle); 27 - font-size: 0.9rem; 28 - line-height: 1.45; 29 - } 30 - 31 - .demo-card-action, 32 - .viewer-hero-link, 33 - .viewer-route-nav a { 34 - display: inline-flex; 35 - align-items: center; 36 - width: fit-content; 37 - margin-top: 0.85rem; 38 - color: var(--color-base); 39 - background: var(--color-primary); 40 - padding: 0.45rem 0.7rem; 41 - text-decoration: none; 42 - font-weight: 700; 43 - } 44 - 45 - .demo-card-action:hover, 46 - .viewer-hero-link:hover, 47 - .viewer-route-nav a:hover { 48 - filter: brightness(1.05); 49 - } 50 - 51 - .demo-context { 52 - margin: 1rem 0 1.5rem; 53 - padding: 0.75rem 1rem; 54 - border-inline-start: 3px solid var(--color-primary); 55 - background: var(--color-surface); 56 - color: var(--color-subtle); 57 - } 58 - 59 - .demo-context strong { 60 - color: var(--color-text); 61 - } 62 - 63 1 .viewer-route-page { 64 2 min-height: 100vh; 65 3 padding: clamp(1rem, 4vw, 3rem); 66 - background: var(--color-background); 4 + background: var(--color-base); 67 5 color: var(--color-text); 68 6 } 69 7 ··· 125 63 126 64 .viewer-demo-button { 127 65 border: 1px solid var(--color-border); 128 - background: var(--color-background); 66 + background: var(--color-base); 129 67 color: var(--color-text); 130 68 padding: 0.45rem 0.65rem; 131 69 cursor: pointer; ··· 145 83 146 84 .model-viewer-stage { 147 85 position: relative; 148 - min-height: min(72vh, 42rem); display: grid; grid-template-rows: minmax(24rem, min(56vh, 34rem)) auto; 86 + min-height: min(72vh, 42rem); 87 + display: grid; 88 + grid-template-rows: minmax(24rem, min(56vh, 34rem)) auto; 149 89 background: 150 90 linear-gradient(135deg, rgb(255 255 255 / 0.06), transparent 30%), 151 91 radial-gradient(circle at 50% 35%, rgb(255 255 255 / 0.12), transparent 24%), 152 - var(--color-background); 92 + var(--color-base); 153 93 } 154 94 155 95 .model-viewer-canvas { 156 96 display: block; 157 97 width: 100%; 158 - min-height: 0; height: 100%; grid-row: 1; grid-column: 1; 98 + min-height: 0; 99 + height: 100%; 100 + grid-row: 1; 101 + grid-column: 1; 159 102 touch-action: none; 160 103 cursor: grab; 161 104 } 162 105 163 - .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; } 106 + .model-viewer-preview { 107 + grid-row: 1; 108 + grid-column: 1; 109 + align-self: stretch; 110 + justify-self: stretch; 111 + width: 100%; 112 + height: 100%; 113 + padding: 2rem; 114 + box-sizing: border-box; 115 + } 116 + 164 117 .model-preview-shadow { fill: rgb(0 0 0 / 0.22); } 165 118 .model-preview-body { fill: color-mix(in srgb, var(--color-primary) 38%, transparent); stroke: var(--color-primary); stroke-width: 4; } 166 - .model-preview-cut { fill: color-mix(in srgb, var(--color-background) 80%, transparent); stroke: var(--color-border); stroke-width: 3; } 119 + .model-preview-cut { fill: color-mix(in srgb, var(--color-base) 80%, transparent); stroke: var(--color-border); stroke-width: 3; } 167 120 .model-preview-line { fill: none; stroke: color-mix(in srgb, var(--color-primary) 70%, var(--color-text)); stroke-width: 3; } 168 121 .model-preview-label { fill: var(--color-muted); font-family: var(--font-mono); font-size: 18px; } 122 + 169 123 .model-viewer-overlay { 170 124 position: relative; 171 - margin: 1rem; max-width: none; padding: 1rem; border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-surface) 92%, transparent); } 125 + margin: 1rem; 126 + max-width: none; 127 + padding: 1rem; 128 + border: 1px solid var(--color-border); 129 + background: color-mix(in srgb, var(--color-surface) 92%, transparent); 130 + } 172 131 173 132 .model-viewer-overlay h2, 174 133 .model-viewer-overlay p,
+71 -83
assets/styling/theme.css
··· 1 - /* Rose Pine theme variables ported for Polymodel 2 - Light: Rose Pine Dawn (moderate contrast variant from weaver.sh) 3 - Dark: Rose Pine (original) via prefers-color-scheme */ 1 + /* Polymodel blueprint foundation tokens. */ 4 2 5 - /* --- Ioskeley Mono (monospace) --- */ 6 3 @font-face { 7 - font-family: "Ioskeley Mono"; 8 - font-style: normal; 9 - font-weight: normal; 10 - src: url("/assets/IoskeleyMono-Regular.woff2") format("woff2"); 11 - } 12 - @font-face { 13 - font-family: "Ioskeley Mono"; 4 + font-family: "Proza Libre"; 14 5 font-style: normal; 15 - font-weight: lighter; 16 - src: url("/assets/IoskeleyMono-Light.woff2") format("woff2"); 6 + font-weight: 400; 7 + src: url("/assets/ProzaLibre-Regular.woff2") format("woff2"); 17 8 } 9 + 18 10 @font-face { 19 - font-family: "Ioskeley Mono"; 11 + font-family: "Proza Libre"; 20 12 font-style: italic; 21 - font-weight: lighter; 22 - src: url("/assets/IoskeleyMono-LightItalic.woff2") format("woff2"); 13 + font-weight: 400; 14 + src: url("/assets/ProzaLibre-Italic.woff2") format("woff2"); 23 15 } 16 + 24 17 @font-face { 25 - font-family: "Ioskeley Mono"; 18 + font-family: "Proza Libre"; 26 19 font-style: normal; 27 - font-weight: bold; 28 - src: url("/assets/IoskeleyMono-Bold.woff2") format("woff2"); 20 + font-weight: 500; 21 + src: url("/assets/ProzaLibre-Medium.woff2") format("woff2"); 29 22 } 23 + 30 24 @font-face { 31 - font-family: "Ioskeley Mono"; 32 - font-style: italic; 33 - font-weight: normal; 34 - src: url("/assets/IoskeleyMono-Italic.woff2") format("woff2"); 35 - } 36 - @font-face { 37 - font-family: "Ioskeley Mono"; 38 - font-style: italic; 39 - font-weight: bold; 40 - src: url("/assets/IoskeleyMono-BoldItalic.woff2") format("woff2"); 25 + font-family: "Proza Libre"; 26 + font-style: normal; 27 + font-weight: 600; 28 + src: url("/assets/ProzaLibre-SemiBold.woff2") format("woff2"); 41 29 } 42 30 43 - /* --- CM Sans (UI sans-serif) --- */ 44 31 @font-face { 45 - font-family: "CM Sans"; 32 + font-family: "Proza Libre"; 46 33 font-style: normal; 47 - font-weight: normal; 48 - src: url("/assets/CMSans-Regular.woff") format("woff"); 49 - } 50 - @font-face { 51 - font-family: "CM Sans"; 52 - font-style: italic; 53 - font-weight: normal; 54 - src: url("/assets/CMSans-Italic.woff") format("woff"); 34 + font-weight: 700; 35 + src: url("/assets/ProzaLibre-Bold.woff2") format("woff2"); 55 36 } 37 + 56 38 @font-face { 57 - font-family: "CM Sans"; 39 + font-family: "Ioskeley Mono"; 58 40 font-style: normal; 59 - font-weight: bold; 60 - src: url("/assets/CMSans-Bold.woff") format("woff"); 41 + font-weight: 400; 42 + src: url("/assets/IoskeleyMono-Regular.woff2") format("woff2"); 61 43 } 44 + 62 45 @font-face { 63 - font-family: "CM Sans"; 64 - font-style: italic; 65 - font-weight: bold; 66 - src: url("/assets/CMSans-BoldItalic.woff") format("woff"); 46 + font-family: "Ioskeley Mono"; 47 + font-style: normal; 48 + font-weight: 700; 49 + src: url("/assets/IoskeleyMono-Bold.woff2") format("woff2"); 67 50 } 68 51 69 - /* --- Light mode: Rose Pine Dawn (moderate contrast) --- */ 70 52 :root { 71 - --color-base: #faf4ed; 72 - --color-surface: #fffaf3; 73 - --color-overlay: #f2e9e1; 74 - --color-text: #1f1d2e; 75 - --color-muted: #635e74; 76 - --color-subtle: #4a4560; 77 - --color-emphasis: #1e1a2d; 78 - --color-primary: #907aa9; 79 - --color-secondary: #56949f; 80 - --color-tertiary: #286983; 81 - --color-error: #b4637a; 82 - --color-warning: #ea9d34; 83 - --color-success: #286983; 84 - --color-border: #908caa; 85 - --color-link: #d7827e; 86 - --color-highlight: #cecacd; 53 + color-scheme: light dark; 54 + 55 + --color-base: #f5f1e8; 56 + --color-surface: #fffdf7; 57 + --color-overlay: #e8edf0; 58 + --color-text: #17252f; 59 + --color-muted: #6a747b; 60 + --color-subtle: #445461; 61 + --color-emphasis: #0c1f2c; 62 + --color-primary: #1f6d8a; 63 + --color-secondary: #5a8797; 64 + --color-tertiary: #0f4159; 65 + --color-error: #a13f32; 66 + --color-warning: #b36a16; 67 + --color-success: #2d7358; 68 + --color-border: #a9b8bf; 69 + --color-link: #a24d1f; 70 + --color-highlight: #d8e5e9; 71 + --color-grid-line: color-mix(in srgb, var(--color-primary) 16%, transparent); 72 + --color-action: #c5652a; 87 73 88 - --font-ui: "DM Sans", system-ui, -apple-system, sans-serif; 89 - --font-heading: "Capriola", sans-serif; 74 + --font-ui: "Proza Libre", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 75 + --font-heading: "Proza Libre", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 90 76 --font-mono: "Ioskeley Mono", "IBM Plex Mono", Consolas, monospace; 91 77 92 78 --spacing-base: 16px; 93 - --spacing-line-height: 1.6; 79 + --spacing-line-height: 1.55; 94 80 --spacing-scale: 1.25; 81 + --radius-panel: 2px; 95 82 } 96 83 97 - /* --- Dark mode: Rose Pine (original) --- */ 98 84 @media (prefers-color-scheme: dark) { 99 85 :root { 100 - --color-base: #191724; 101 - --color-surface: #1f1d2e; 102 - --color-overlay: #26233a; 103 - --color-text: #e0def4; 104 - --color-muted: #6e6a86; 105 - --color-subtle: #908caa; 106 - --color-emphasis: #e0def4; 107 - --color-primary: #c4a7e7; 108 - --color-secondary: #3e8fb0; 109 - --color-tertiary: #9ccfd8; 110 - --color-error: #eb6f92; 111 - --color-warning: #f6c177; 112 - --color-success: #31748f; 113 - --color-border: #403d52; 114 - --color-link: #ebbcba; 115 - --color-highlight: #524f67; 86 + --color-base: #07141c; 87 + --color-surface: #0e202a; 88 + --color-overlay: #152d39; 89 + --color-text: #e5edf0; 90 + --color-muted: #8ea1aa; 91 + --color-subtle: #b7c7ce; 92 + --color-emphasis: #f5fbfd; 93 + --color-primary: #75bfd1; 94 + --color-secondary: #4f91a6; 95 + --color-tertiary: #a8dce8; 96 + --color-error: #ef846f; 97 + --color-warning: #e5a852; 98 + --color-success: #7fcaa8; 99 + --color-border: #2a4856; 100 + --color-link: #f0a06a; 101 + --color-highlight: #193847; 102 + --color-grid-line: color-mix(in srgb, var(--color-primary) 22%, transparent); 103 + --color-action: #e38b4b; 116 104 } 117 105 }
+29 -4
e2e/tests/home.spec.ts
··· 1 1 import { expect, test } from '@playwright/test'; 2 2 3 - test('home page shows Polymodel product direction', async ({ page }) => { 3 + test('home page shows the product browse shell', async ({ page }) => { 4 4 await page.goto('/'); 5 + 5 6 await expect(page.getByRole('heading', { name: 'Polymodel' })).toBeVisible(); 6 - await expect(page.getByText('Thingiverse on atproto')).toBeVisible(); 7 - await expect(page.getByText('Model viewer')).toBeVisible(); 8 - await expect(page.getByText('Polytoken workflow')).toBeVisible(); 7 + await expect(page.getByLabel('Browse shell')).toBeVisible(); 8 + await expect(page.getByLabel('Browse results placeholder')).toBeVisible(); 9 + await expect(page.getByText('Empty')).toBeVisible(); 10 + await expect(page.getByRole('tab', { name: 'Recent' })).toBeVisible(); 11 + await expect(page.getByRole('tab', { name: 'Hot' })).toBeVisible(); 12 + await expect(page.getByRole('searchbox', { name: 'Search model projects' })).toBeVisible(); 13 + }); 14 + 15 + test('foundation route shows PM-38 primitives and reusable states', async ({ page }) => { 16 + await page.goto('/foundation'); 17 + 18 + await expect(page.getByRole('heading', { name: 'Foundation', exact: true })).toBeVisible(); 19 + await expect(page.getByText('Token and asset check')).toBeVisible(); 20 + await expect(page.getByLabel('Foundation primitives')).toBeVisible(); 21 + await expect(page.getByLabel('Reusable states')).toBeVisible(); 22 + await expect(page.getByText('No projects yet')).toBeVisible(); 23 + await expect(page.getByText('Feed unavailable')).toBeVisible(); 24 + }); 25 + 26 + test('home page no longer uses workflow roadmap demo copy as the product surface', async ({ page }) => { 27 + await page.goto('/'); 28 + 29 + await expect(page.getByText('Thingiverse on atproto')).toHaveCount(0); 30 + await expect(page.getByText('Project lexicon')).toHaveCount(0); 31 + await expect(page.getByText('Jira and Confluence')).toHaveCount(0); 32 + await expect(page.getByText('Polytoken workflow')).toHaveCount(0); 33 + await expect(page.getByText('PM-38 foundation')).toHaveCount(0); 9 34 });
+1 -1
e2e/tests/viewer.spec.ts
··· 20 20 21 21 test('viewer is reachable from browse and displays the committed STL asset', async ({ page }) => { 22 22 await page.goto('/'); 23 - await page.locator('header').getByRole('link', { name: 'Open viewer' }).click(); 23 + await page.getByRole('link', { name: 'Open viewer' }).click(); 24 24 25 25 await expect(page).toHaveURL(/\/viewer\/body-f-chest-v4$/); 26 26 await expectViewerReady(page);
+2
rustfmt.toml
··· 1 + # crates/polymodel-api is generated by jacquard; do not make PM slices carry rustfmt churn there. 2 + ignore = ["crates/polymodel-api"]
+62
src/fonts.rs
··· 1 + use dioxus::prelude::*; 2 + 3 + // Self-hosted font assets used by assets/styling/theme.css. Hash suffixes are 4 + // disabled so @font-face URLs stay stable across builds. 5 + 6 + // Proza Libre (UI and headings) 7 + #[used] 8 + static _FONT_PROZA_REGULAR: Asset = asset!( 9 + "/assets/fonts/proza-libre/ProzaLibre-Regular.woff2", 10 + AssetOptions::builder() 11 + .with_hash_suffix(false) 12 + .into_asset_options() 13 + ); 14 + 15 + #[used] 16 + static _FONT_PROZA_ITALIC: Asset = asset!( 17 + "/assets/fonts/proza-libre/ProzaLibre-Italic.woff2", 18 + AssetOptions::builder() 19 + .with_hash_suffix(false) 20 + .into_asset_options() 21 + ); 22 + 23 + #[used] 24 + static _FONT_PROZA_MEDIUM: Asset = asset!( 25 + "/assets/fonts/proza-libre/ProzaLibre-Medium.woff2", 26 + AssetOptions::builder() 27 + .with_hash_suffix(false) 28 + .into_asset_options() 29 + ); 30 + 31 + #[used] 32 + static _FONT_PROZA_SEMIBOLD: Asset = asset!( 33 + "/assets/fonts/proza-libre/ProzaLibre-SemiBold.woff2", 34 + AssetOptions::builder() 35 + .with_hash_suffix(false) 36 + .into_asset_options() 37 + ); 38 + 39 + #[used] 40 + static _FONT_PROZA_BOLD: Asset = asset!( 41 + "/assets/fonts/proza-libre/ProzaLibre-Bold.woff2", 42 + AssetOptions::builder() 43 + .with_hash_suffix(false) 44 + .into_asset_options() 45 + ); 46 + 47 + // Ioskeley Mono (metadata and technical labels) 48 + #[used] 49 + static _FONT_MONO_REGULAR: Asset = asset!( 50 + "/assets/fonts/ioskeley-mono/IoskeleyMono-Regular.woff2", 51 + AssetOptions::builder() 52 + .with_hash_suffix(false) 53 + .into_asset_options() 54 + ); 55 + 56 + #[used] 57 + static _FONT_MONO_BOLD: Asset = asset!( 58 + "/assets/fonts/ioskeley-mono/IoskeleyMono-Bold.woff2", 59 + AssetOptions::builder() 60 + .with_hash_suffix(false) 61 + .into_asset_options() 62 + );
+148 -79
src/main.rs
··· 1 1 use dioxus::prelude::*; 2 2 3 + mod fonts; 3 4 mod mesh; 4 5 mod viewer; 5 6 ··· 29 30 let _ = tracing_subscriber::fmt() 30 31 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 31 32 .try_init(); 32 - 33 + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); // Hydrant selects AWS-LC while other deps enable ring; choose before websocket TLS setup. 33 34 let cfg = indexing::config::ServerConfig::load(); 34 35 let db = indexing::db::init_db(&cfg) 35 36 .await ··· 57 58 enum Route { 58 59 #[route("/")] 59 60 Browse {}, 61 + #[route("/foundation")] 62 + Foundation {}, 60 63 #[route("/viewer/:model_id")] 61 64 Viewer { model_id: String }, 62 65 } ··· 67 70 document::Meta { name: "viewport", content: "width=device-width, initial-scale=1" } 68 71 document::Title { "Polymodel" } 69 72 document::Link { rel: "icon", href: FAVICON } 70 - document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" } 71 - document::Link { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "anonymous" } 72 - document::Link { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Capriola&family=DM+Sans:opsz,wght@9..40,300..800&display=swap" } 73 73 Stylesheet { href: THEME_CSS } 74 74 Stylesheet { href: BASE_CSS } 75 75 Stylesheet { href: CARDS_CSS } ··· 82 82 83 83 #[component] 84 84 fn Browse() -> Element { 85 + let viewer_model = demo_models()[0].id.to_string(); 86 + 85 87 rsx! { 86 - main { class: "browse-page", 87 - header { class: "browse-header", 88 - h1 { "Polymodel" } 89 - div { class: "demo-context", 90 - strong { "Thingiverse on atproto. " } 91 - "Project/profile/like lexicons, social feeds, and a browser STL viewer. " 92 - strong { "Workflow: " } 93 - "Jira + Confluence + jj + Polytoken." 88 + main { class: "browse-page product-shell", 89 + header { class: "product-header", 90 + div { class: "product-kicker", "ATProto model sharing" } 91 + div { class: "product-header-row", 92 + div { 93 + h1 { "Polymodel" } 94 + } 95 + nav { class: "product-nav", aria_label: "Primary", 96 + a { href: "#browse-shell", "Browse" } 97 + Link { to: Route::Foundation {}, "Foundation" } 98 + Link { 99 + class: "button button-secondary", 100 + to: Route::Viewer { model_id: viewer_model }, 101 + "Open viewer" 102 + } 103 + } 104 + } 105 + } 106 + 107 + section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", 108 + label { class: "search-field", 109 + span { "Search model projects" } 110 + input { 111 + r#type: "search", 112 + placeholder: "Search by project, tag, or maker", 113 + aria_label: "Search model projects" 114 + } 94 115 } 95 - Link { 96 - class: "viewer-hero-link", 97 - to: Route::Viewer { model_id: demo_models()[0].id.to_string() }, 98 - "Open viewer" 116 + div { class: "tab-row", role: "tablist", aria_label: "Feed placeholders", 117 + button { class: "tab is-active", role: "tab", aria_selected: "true", "Recent" } 118 + button { class: "tab", role: "tab", aria_selected: "false", "Hot" } 119 + button { class: "tab", role: "tab", aria_selected: "false", "Following" } 99 120 } 100 121 } 101 - section { class: "browse-grid", 102 - for card in roadmap_cards() { 103 - RoadmapCard { card } 122 + 123 + section { class: "state-card empty-state", aria_label: "Browse results placeholder", 124 + span { class: "status-pill status-muted", "Empty" } 125 + } 126 + } 127 + } 128 + } 129 + 130 + #[component] 131 + fn Foundation() -> Element { 132 + rsx! { 133 + main { class: "browse-page product-shell", 134 + header { class: "product-header", 135 + div { class: "product-kicker", "PM-38 foundation" } 136 + div { class: "product-header-row", 137 + div { 138 + h1 { "Foundation" } 139 + p { class: "product-lede", 140 + "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." 141 + } 142 + } 143 + nav { class: "product-nav", aria_label: "Foundation navigation", 144 + Link { to: Route::Browse {}, "Back to home" } 145 + a { href: "#ui-states", "UI states" } 146 + } 147 + } 148 + } 149 + 150 + section { id: "browse-foundation", class: "foundation-hero blueprint-panel", 151 + div { class: "foundation-hero-copy", 152 + p { class: "eyebrow", "Token and asset check" } 153 + h2 { "Blueprint styling foundation" } 154 + p { 155 + "This route keeps PM-38 primitives visible without making implementation scaffolding the product home." 156 + } 157 + div { class: "button-row", 158 + button { class: "button button-primary", "Primary action" } 159 + button { class: "button button-ghost", "Ghost action" } 160 + } 161 + } 162 + div { class: "blueprint-preview", aria_label: "Blueprint placeholder preview", 163 + div { class: "blueprint-preview-grid" } 164 + span { class: "blueprint-axis blueprint-axis-x", "X" } 165 + span { class: "blueprint-axis blueprint-axis-y", "Y" } 166 + span { class: "blueprint-dimension", "120 × 86 × 42 mm" } 167 + } 168 + } 169 + 170 + section { class: "foundation-grid", aria_label: "Foundation primitives", 171 + for item in foundation_items() { 172 + FoundationCard { item } 173 + } 174 + } 175 + 176 + section { id: "ui-states", class: "state-grid", aria_label: "Reusable states", 177 + div { class: "state-card loading-state", 178 + span { class: "status-pill", "Loading" } 179 + div { class: "skeleton-line skeleton-wide" } 180 + div { class: "skeleton-line" } 181 + div { class: "skeleton-box" } 182 + } 183 + div { class: "state-card empty-state", 184 + span { class: "status-pill status-muted", "Empty" } 185 + h2 { "No projects yet" } 186 + p { "Empty browse states can use this panel before live feeds are connected." } 187 + } 188 + div { class: "state-card error-state", 189 + span { class: "status-pill status-error", "Error" } 190 + h2 { "Feed unavailable" } 191 + p { "Error states keep the browse surface useful without inventing data plumbing in this slice." } 104 192 } 105 193 } 106 194 } ··· 122 210 } 123 211 124 212 #[derive(Clone, Copy, PartialEq)] 125 - struct RoadmapCardData { 126 - token: &'static str, 213 + struct FoundationItem { 214 + label: &'static str, 127 215 title: &'static str, 128 216 body: &'static str, 129 - viewer_link: Option<&'static str>, 217 + meta: &'static str, 130 218 } 131 219 132 - fn roadmap_cards() -> [RoadmapCardData; 6] { 220 + fn foundation_items() -> [FoundationItem; 3] { 133 221 [ 134 - RoadmapCardData { 135 - token: "NSID", 136 - title: "Project lexicon", 137 - body: "Define the model/project record: title, description, files, preview assets, tags, license, and attribution.", 138 - viewer_link: None, 139 - }, 140 - RoadmapCardData { 141 - token: "DID", 142 - title: "Profile publishing", 143 - body: "Use ATProto identity and profile-oriented feeds so makers can publish and browse from their own repos.", 144 - viewer_link: None, 222 + FoundationItem { 223 + label: "Panel", 224 + title: "Project card surface", 225 + body: "A crisp bordered card for future model summaries, previews, and maker metadata.", 226 + meta: "primitive/card", 145 227 }, 146 - RoadmapCardData { 147 - token: "★", 148 - title: "Likes and saves", 149 - body: "Start with lightweight feedback records that can drive hot/recent discovery without inventing a heavyweight backend.", 150 - viewer_link: None, 228 + FoundationItem { 229 + label: "Input", 230 + title: "Search and filter shell", 231 + body: "Reusable input styling for browse, profile, and publish flows without binding to a data source yet.", 232 + meta: "primitive/input", 151 233 }, 152 - RoadmapCardData { 153 - token: "STL", 154 - title: "Model viewer", 155 - body: "Spike three-d and kiss3d as WASM viewer candidates; choose based on STL loading, orbit controls, rendering quality, and Dioxus integration friction.", 156 - viewer_link: Some("body-f-chest-v4"), 157 - }, 158 - RoadmapCardData { 159 - token: "PM", 160 - title: "Jira and Confluence", 161 - body: "Use Jira and Confluence as both planning/demo narrative and agent-readable workflow state.", 162 - viewer_link: None, 163 - }, 164 - RoadmapCardData { 165 - token: "pt", 166 - title: "Polytoken workflow", 167 - body: "Use facets, skills, hooks, project vars, jj workspaces, and review gates to make the demo workflow real.", 168 - viewer_link: None, 234 + FoundationItem { 235 + label: "Status", 236 + title: "Metadata and chips", 237 + body: "Compact mono labels for license, dimensions, file state, and feed status.", 238 + meta: "primitive/status", 169 239 }, 170 240 ] 171 241 } 172 242 173 243 #[component] 174 - fn RoadmapCard(card: RoadmapCardData) -> Element { 244 + fn FoundationCard(item: FoundationItem) -> Element { 175 245 rsx! { 176 - article { class: "video-card card-surface demo-card", 177 - div { class: "video-card-thumbnail", 178 - span { class: "demo-card-token", "{card.token}" } 246 + article { class: "foundation-card card-surface", 247 + div { class: "card-media blueprint-media", 248 + span { class: "status-pill", "{item.label}" } 179 249 } 180 - div { class: "video-card-body demo-card-body", 181 - h2 { class: "card-title", "{card.title}" } 182 - p { "{card.body}" } 183 - if let Some(model_id) = card.viewer_link { 184 - Link { 185 - class: "demo-card-action", 186 - to: Route::Viewer { model_id: model_id.to_string() }, 187 - "Open viewer" 188 - } 189 - } 250 + div { class: "card-body", 251 + h2 { class: "card-title", "{item.title}" } 252 + p { "{item.body}" } 253 + span { class: "card-meta", "{item.meta}" } 190 254 } 191 255 } 192 256 } ··· 194 258 195 259 #[cfg(test)] 196 260 mod tests { 197 - use super::roadmap_cards; 261 + use super::foundation_items; 198 262 199 263 #[test] 200 - fn roadmap_includes_stl_viewer() { 201 - assert!(roadmap_cards().iter().any(|card| card.token == "STL")); 264 + fn foundation_items_cover_base_primitives() { 265 + let items = foundation_items(); 266 + assert!(items.iter().any(|item| item.label == "Panel")); 267 + assert!(items.iter().any(|item| item.label == "Input")); 268 + assert!(items.iter().any(|item| item.label == "Status")); 202 269 } 203 270 204 271 #[test] 205 - fn roadmap_links_to_viewer() { 206 - assert!( 207 - roadmap_cards() 208 - .iter() 209 - .any(|card| card.viewer_link.is_some()) 210 - ); 272 + fn foundation_copy_avoids_workflow_marketing() { 273 + let combined = foundation_items() 274 + .iter() 275 + .map(|item| item.body) 276 + .collect::<Vec<_>>() 277 + .join(" "); 278 + assert!(!combined.contains("Jira")); 279 + assert!(!combined.contains("Polytoken")); 211 280 } 212 281 }