atproto Thingiverse but good
12

Configure Feed

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

PM-39: product shell, discovery home, CSS reorg

Orual (Jun 29, 2026, 7:06 PM EDT) 4f9fe3db 83f4ac28

+1307 -814
+2
AGENTS.md
··· 72 72 - 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. 73 73 - 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. 74 74 - 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. 75 + - 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. 76 + - `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`). 75 77 76 78 ### Lexicons & codegen 77 79
+6
Cargo.toml
··· 149 149 "fmt", 150 150 "env-filter", 151 151 ] } 152 + # Mirror the optional server-only deps so the `#[cfg(any(feature = "server", 153 + # test))]` server modules (indexing/appview/oauth) compile and their tests run 154 + # under a plain `cargo test`, not only `--features server`. 155 + axum-extra = { version = "0.10", features = ["cookie"] } 156 + base64 = "0.22" 157 + keyring = "3" 152 158 153 159 [build-dependencies] 154 160 dotenvy = "0.15"
+5
DESIGN.md
··· 36 36 rounded: 37 37 panel: "2px" 38 38 square: "0px" 39 + pill: "999px" 39 40 spacing: 40 41 base: "16px" 41 42 components: ··· 148 149 ### Shadow Vocabulary 149 150 - **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. 150 151 152 + ### Overlay tokens 153 + - 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(...)`. 154 + 151 155 ### Named Rules 152 156 **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). 153 157 ··· 162 166 ### Chips / Tabs / Status pills 163 167 - **Style:** 1px border, near-square, mono text, ~0.72rem. Transparent at rest. 164 168 - **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. 169 + - **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. 165 170 166 171 ### Cards / Containers 167 172 - **Corner Style:** 2px (panel radius).
+1 -2
assets/styling/base.css
··· 76 76 to { opacity: 1; } 77 77 } 78 78 79 - .browse-page, 80 - .watch-page { 79 + .browse-page { 81 80 animation: fade-in 0.2s ease-out; 82 81 }
-521
assets/styling/browse.css
··· 1 - /* Browse foundation layout. */ 2 - 3 - .browse-page { 4 - min-height: 100vh; 5 - max-width: 1180px; 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); 26 - } 27 - 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); 53 - } 54 - 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); 62 - font-weight: 600; 63 - padding: 0.45rem 0; 64 - } 65 - .session-control { 66 - display: inline-flex; 67 - align-items: stretch; 68 - gap: 0; 69 - flex-wrap: nowrap; 70 - } 71 - .session-control input { 72 - width: min(9.5rem, 42vw); 73 - min-height: 2rem; 74 - padding: 0.45rem 0.65rem; 75 - border: 1px solid var(--color-border); 76 - border-right: 0; 77 - border-radius: var(--radius-sm) 0 0 var(--radius-sm); 78 - background: var(--color-surface); 79 - color: var(--color-text); 80 - font-family: var(--font-ui); 81 - font-size: 0.9rem; 82 - } 83 - .session-control input:focus { 84 - outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 85 - outline-offset: 1px; 86 - border-color: var(--color-primary); 87 - } 88 - .session-control .button { 89 - min-height: 2rem; 90 - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 91 - } 92 - .session-control-authenticated { 93 - justify-content: flex-end; 94 - gap: 0.5rem; 95 - } 96 - .foundation-hero { 97 - display: grid; 98 - grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); 99 - gap: clamp(1rem, 3vw, 2rem); 100 - padding: clamp(1rem, 3vw, 1.5rem); 101 - scroll-margin-top: 1rem; 102 - } 103 - 104 - .foundation-hero-copy { 105 - display: grid; 106 - align-content: center; 107 - gap: 0.9rem; 108 - } 109 - 110 - .foundation-hero-copy p { 111 - margin: 0; 112 - color: var(--color-subtle); 113 - max-width: 42rem; 114 - } 115 - 116 - .blueprint-preview { 117 - min-height: 18rem; 118 - border: 1px solid var(--color-border); 119 - } 120 - 121 - .blueprint-preview::before, 122 - .blueprint-preview::after { 123 - content: ""; 124 - position: absolute; 125 - inset: 18% 16%; 126 - border: 1px solid var(--color-primary); 127 - } 128 - 129 - .blueprint-preview::after { 130 - inset: 32% 26%; 131 - border-color: var(--color-action); 132 - } 133 - 134 - .blueprint-axis, 135 - .blueprint-dimension { 136 - position: absolute; 137 - font-family: var(--font-mono); 138 - font-size: 0.72rem; 139 - color: var(--color-primary); 140 - } 141 - 142 - .blueprint-axis-x { 143 - right: 0.75rem; 144 - bottom: 0.75rem; 145 - } 146 - .blueprint-axis-y { 147 - left: 0.75rem; 148 - top: 0.75rem; 149 - } 150 - .blueprint-dimension { 151 - left: 0.75rem; 152 - bottom: 0.75rem; 153 - } 154 - 155 - .browse-controls { 156 - display: grid; 157 - grid-template-columns: minmax(0, 1fr) auto; 158 - gap: 1rem; 159 - align-items: end; 160 - padding: 1rem; 161 - } 162 - 163 - .browse-results-grid, 164 - .foundation-grid, 165 - .state-grid { 166 - display: grid; 167 - grid-template-columns: repeat(3, minmax(0, 1fr)); 168 - gap: 1rem; 169 - scroll-margin-top: 1rem; 170 - } 171 - 172 - .foundation-card { 173 - display: grid; 174 - grid-template-rows: auto 1fr; 175 - min-width: 0; 176 - overflow: hidden; 177 - } 178 - 179 - .state-card { 180 - display: grid; 181 - align-content: start; 182 - gap: 0.75rem; 183 - min-height: 11rem; 184 - padding: 1rem; 185 - } 186 - 187 - .state-card p { 188 - margin: 0; 189 - color: var(--color-subtle); 190 - } 191 - 192 - .error-state { 193 - border-color: color-mix(in srgb, var(--color-error) 55%, var(--color-border)); 194 - } 195 - 196 - .thing-detail-page { 197 - min-height: 100vh; 198 - max-width: 1180px; 199 - margin: 0 auto; 200 - padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 201 - } 202 - 203 - .detail-nav { 204 - display: flex; 205 - } 206 - 207 - .detail-nav a { 208 - color: var(--color-subtle); 209 - font-weight: 600; 210 - } 211 - 212 - .thing-detail-header, 213 - .thing-detail-summary, 214 - .thing-detail-copy, 215 - .detail-panel, 216 - .selected-model-panel, 217 - .parts-list, 218 - .part-row-body { 219 - display: flex; 220 - flex-direction: column; 221 - } 222 - 223 - .thing-detail-header { 224 - gap: 0.5rem; 225 - } 226 - 227 - .thing-detail-title-row { 228 - display: flex; 229 - justify-content: space-between; 230 - align-items: flex-end; 231 - gap: 1rem; 232 - } 233 - 234 - .thing-detail-title-row h1, 235 - .detail-panel h2, 236 - .selected-model-panel h2, 237 - .part-row h3 { 238 - margin: 0; 239 - color: var(--color-emphasis); 240 - letter-spacing: -0.03em; 241 - } 242 - 243 - .thing-detail-title-row h1 { 244 - font-size: clamp(2.1rem, 5vw, 4rem); 245 - line-height: 0.95; 246 - } 247 - 248 - .thing-detail-creator, 249 - .detail-muted, 250 - .model-row-summary, 251 - .part-notes, 252 - .viewer-unavailable p { 253 - margin: 0; 254 - color: var(--color-subtle); 255 - } 256 - 257 - .thing-detail-stats, 258 - .part-row-meta { 259 - display: flex; 260 - flex-wrap: wrap; 261 - gap: 0.4rem 0.8rem; 262 - margin: 0; 263 - color: var(--color-muted); 264 - font-family: var(--font-mono); 265 - font-size: 0.76rem; 266 - } 267 - 268 - .thing-detail-stats div, 269 - .part-row-meta div { 270 - display: contents; 271 - } 272 - 273 - .thing-detail-stats dt, 274 - .part-row-meta dt { 275 - position: absolute; 276 - width: 1px; 277 - height: 1px; 278 - padding: 0; 279 - overflow: hidden; 280 - clip: rect(0, 0, 0, 0); 281 - white-space: nowrap; 282 - border: 0; 283 - } 284 - 285 - .thing-detail-stats dd, 286 - .part-row-meta dd { 287 - margin: 0; 288 - } 289 - 290 - .thing-detail-shell { 291 - display: grid; 292 - grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); 293 - align-items: start; 294 - gap: clamp(1rem, 3vw, 1.5rem); 295 - padding: clamp(1rem, 3vw, 1.5rem); 296 - } 297 - 298 - .thing-detail-viewer { 299 - aspect-ratio: 4 / 3; 300 - min-height: 20rem; 301 - max-height: 34rem; 302 - border: 1px solid var(--color-border); 303 - border-radius: var(--radius-panel); 304 - overflow: hidden; 305 - } 306 - 307 - .detail-asset-viewer, 308 - .detail-asset-viewer .model-viewer-boundary, 309 - .detail-asset-viewer .model-viewer-stage { 310 - height: 100%; 311 - min-height: 0; 312 - } 313 - 314 - .detail-asset-viewer .model-viewer-boundary { 315 - border: 0; 316 - background: transparent; 317 - } 318 - 319 - .detail-asset-viewer .model-viewer-stage { 320 - grid-template-rows: 1fr; 321 - } 322 - 323 - .model-viewer-preview-image { 324 - grid-row: 1; 325 - grid-column: 1; 326 - width: 100%; 327 - height: 100%; 328 - object-fit: cover; 329 - } 330 - 331 - .thing-detail-summary { 332 - justify-content: space-between; 333 - gap: 1rem; 334 - } 335 - 336 - .thing-detail-copy { 337 - gap: 0.85rem; 338 - } 339 - 340 - .thing-detail-chips, 341 - .thing-detail-actions { 342 - display: flex; 343 - flex-wrap: wrap; 344 - gap: 0.5rem; 345 - } 346 - 347 - .thing-detail-actions { 348 - align-items: center; 349 - } 350 - 351 - .thing-detail-actions .button-primary { 352 - min-width: 9.5rem; 353 - } 354 - 355 - .button:disabled, 356 - .button[disabled], 357 - .model-row:disabled, 358 - .model-row[disabled] { 359 - cursor: not-allowed; 360 - opacity: 0.52; 361 - filter: saturate(0.7); 362 - } 363 - 364 - .button:disabled:hover, 365 - .button[disabled]:hover { 366 - border-color: var(--color-border); 367 - color: var(--color-text); 368 - filter: saturate(0.7); 369 - } 370 - 371 - .thing-detail-grid { 372 - display: grid; 373 - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); 374 - gap: 1rem; 375 - align-items: start; 376 - } 377 - 378 - .detail-panel { 379 - gap: 0.85rem; 380 - padding: 1rem; 381 - } 382 - 383 - .thing-detail-grid > .detail-panel:nth-child(-n + 2) { 384 - align-self: stretch; 385 - height: 100%; 386 - } 387 - 388 - .detail-panel-heading { 389 - display: flex; 390 - align-items: baseline; 391 - } 392 - 393 - .detail-panel-heading h2, 394 - .selected-model-heading h2 { 395 - font-size: clamp(1.25rem, 2vw, 1.6rem); 396 - line-height: 1.1; 397 - } 398 - 399 - .selected-model-heading { 400 - display: flex; 401 - flex-direction: column; 402 - gap: 0.3rem; 403 - } 404 - 405 - .instruction-list, 406 - .print-settings { 407 - margin: 0; 408 - padding-left: 1.25rem; 409 - color: var(--color-text); 410 - } 411 - 412 - .instruction-list li + li, 413 - .print-settings li + li { 414 - margin-top: 0.35rem; 415 - } 416 - 417 - .model-list { 418 - display: flex; 419 - flex-direction: column; 420 - gap: 0.55rem; 421 - } 422 - 423 - .model-row { 424 - display: flex; 425 - flex-direction: column; 426 - gap: 0.25rem; 427 - width: 100%; 428 - padding: 0.75rem; 429 - border: 1px solid var(--color-border); 430 - border-radius: var(--radius-panel); 431 - background: var(--color-base); 432 - color: var(--color-text); 433 - text-align: left; 434 - } 435 - 436 - .model-row:hover, 437 - .model-row.is-selected { 438 - border-color: var(--color-primary); 439 - } 440 - 441 - .model-row.is-selected { 442 - background: color-mix(in srgb, var(--color-primary) 8%, transparent); 443 - } 444 - 445 - .model-row-title { 446 - font-weight: 700; 447 - } 448 - 449 - .model-row-meta { 450 - color: var(--color-muted); 451 - font-family: var(--font-mono); 452 - font-size: 0.75rem; 453 - } 454 - 455 - .selected-model-panel, 456 - .part-row-body { 457 - gap: 0.65rem; 458 - } 459 - 460 - .parts-list { 461 - gap: 0.75rem; 462 - } 463 - 464 - .part-row { 465 - display: flex; 466 - gap: 0.75rem; 467 - padding: 0.75rem; 468 - border: 1px solid var(--color-border); 469 - border-radius: var(--radius-panel); 470 - background: var(--color-base); 471 - } 472 - 473 - .part-row-index { 474 - display: inline-flex; 475 - align-items: center; 476 - justify-content: center; 477 - flex: 0 0 2rem; 478 - width: 2rem; 479 - height: 2rem; 480 - border: 1px solid var(--color-primary); 481 - border-radius: 999px; 482 - color: var(--color-primary); 483 - font-family: var(--font-mono); 484 - font-size: 0.78rem; 485 - } 486 - 487 - .viewer-unavailable { 488 - min-height: 12rem; 489 - padding: 1rem; 490 - border: 1px solid var(--color-border); 491 - border-radius: var(--radius-panel); 492 - display: flex; 493 - flex-direction: column; 494 - justify-content: space-between; 495 - gap: 1rem; 496 - } 497 - 498 - @media (max-width: 820px) { 499 - .foundation-hero, 500 - .browse-controls, 501 - .thing-detail-shell, 502 - .thing-detail-grid { 503 - grid-template-columns: 1fr; 504 - } 505 - 506 - .product-header-row, 507 - .thing-detail-title-row { 508 - display: grid; 509 - align-items: start; 510 - } 511 - 512 - .product-nav { 513 - justify-content: flex-start; 514 - } 515 - 516 - .browse-results-grid, 517 - .foundation-grid, 518 - .state-grid { 519 - grid-template-columns: 1fr; 520 - } 521 - }
+151 -37
assets/styling/cards.css assets/styling/primitives.css
··· 1 - /* Reusable card, button, input, chip, and state primitives. */ 1 + /* Reusable primitives: panels, cards, buttons, tabs, pills, skeletons, and the 2 + shared blueprint vocabulary. Depends on theme.css tokens + base.css reset; 3 + loads before shell and page layers. */ 4 + 5 + /* Page-shell flex utility for routes that stack their sections vertically. */ 6 + .product-shell { 7 + display: flex; 8 + flex-direction: column; 9 + gap: 0.9rem; 10 + } 11 + 12 + /* Shared lede + eyebrow text. The mono eyebrow is for deliberate, occasional 13 + use (One-Eyebrow rule), never stacked above every section. */ 14 + .product-lede { 15 + max-width: 44rem; 16 + margin: 0.7rem 0 0; 17 + color: var(--color-subtle); 18 + font-size: clamp(1rem, 2vw, 1.18rem); 19 + } 20 + 21 + .product-kicker, 22 + .eyebrow { 23 + color: var(--color-primary); 24 + font-family: var(--font-mono); 25 + font-size: 0.72rem; 26 + letter-spacing: 0.08em; 27 + text-transform: uppercase; 28 + } 29 + 30 + /* Panels and cards */ 2 31 3 32 .card-surface, 4 33 .blueprint-panel, ··· 36 65 font-size: 0.74rem; 37 66 } 38 67 68 + /* Drafting-grid media wells and placeholders */ 69 + 39 70 .card-media, 40 71 .blueprint-media, 41 72 .blueprint-preview { ··· 53 84 padding: 0.75rem; 54 85 } 55 86 87 + .blueprint-axis, 88 + .blueprint-dimension { 89 + position: absolute; 90 + font-family: var(--font-mono); 91 + font-size: 0.72rem; 92 + color: var(--color-primary); 93 + } 94 + 95 + .blueprint-axis-x { 96 + right: 0.75rem; 97 + bottom: 0.75rem; 98 + } 99 + 100 + .blueprint-axis-y { 101 + left: 0.75rem; 102 + top: 0.75rem; 103 + } 104 + 105 + .blueprint-dimension { 106 + left: 0.75rem; 107 + bottom: 0.75rem; 108 + } 109 + 110 + /* Thing cards */ 111 + 56 112 .thing-card { 57 113 display: grid; 58 114 grid-template-rows: auto 1fr; ··· 155 211 margin: 0; 156 212 } 157 213 214 + /* Visually-hidden utility: keeps a heading/label in the accessibility tree 215 + (e.g. a route's <h1> during a loading skeleton) without rendering it. */ 216 + .sr-only { 217 + position: absolute; 218 + width: 1px; 219 + height: 1px; 220 + padding: 0; 221 + margin: -1px; 222 + overflow: hidden; 223 + clip: rect(0, 0, 0, 0); 224 + white-space: nowrap; 225 + border: 0; 226 + } 227 + 158 228 .thing-card-action { 159 229 cursor: default; 160 230 color: var(--color-muted); ··· 164 234 text-transform: uppercase; 165 235 } 166 236 167 - .button, 168 - .viewer-route-nav a { 237 + /* Creator links on cards and thing detail */ 238 + 239 + .thing-card-creator-link, 240 + .thing-detail-creator-link { 241 + color: var(--color-secondary); 242 + } 243 + 244 + .thing-card-creator-link:hover, 245 + .thing-detail-creator-link:hover { 246 + color: var(--color-link); 247 + } 248 + 249 + /* Buttons */ 250 + 251 + .button { 169 252 display: inline-flex; 170 253 align-items: center; 171 254 justify-content: center; ··· 183 266 text-decoration: none; 184 267 } 185 268 186 - .button:hover, 187 - .viewer-route-nav a:hover { 269 + .button:hover { 188 270 border-color: var(--color-primary); 189 271 color: var(--color-tertiary); 190 272 text-decoration: none; ··· 210 292 background: transparent; 211 293 } 212 294 213 - .button-row, 214 - .tab-row, 215 - .product-nav { 216 - display: flex; 217 - flex-wrap: wrap; 218 - gap: 0.5rem; 295 + .button:disabled, 296 + .button[disabled] { 297 + cursor: not-allowed; 298 + opacity: 0.52; 299 + filter: saturate(0.7); 219 300 } 220 301 221 - .search-field { 222 - display: grid; 223 - gap: 0.35rem; 224 - color: var(--color-subtle); 225 - font-size: 0.85rem; 302 + .button:disabled:hover, 303 + .button[disabled]:hover { 304 + border-color: var(--color-border); 305 + color: var(--color-text); 306 + filter: saturate(0.7); 226 307 } 227 308 228 - .search-field span, 229 - .eyebrow, 230 - .product-kicker { 231 - font-family: var(--font-mono); 232 - font-size: 0.72rem; 233 - letter-spacing: 0.08em; 234 - text-transform: uppercase; 309 + .button-row, 310 + .tab-row { 311 + display: flex; 312 + flex-wrap: wrap; 313 + gap: 0.5rem; 235 314 } 236 315 237 - .search-field input { 238 - width: 100%; 239 - padding: 0.75rem 0.85rem; 240 - border: 1px solid var(--color-border); 241 - border-radius: var(--radius-panel); 242 - background: var(--color-base); 243 - color: var(--color-text); 244 - font-family: var(--font-ui); 245 - } 246 - 247 - .search-field input:focus { 248 - outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 249 - border-color: var(--color-primary); 250 - } 316 + /* Tabs, status pills */ 251 317 252 318 .tab, 253 319 .status-pill { ··· 271 337 background: color-mix(in srgb, var(--color-primary) 8%, transparent); 272 338 } 273 339 340 + .tab:disabled, 341 + .tab[disabled] { 342 + cursor: not-allowed; 343 + opacity: 0.52; 344 + filter: saturate(0.7); 345 + } 346 + 274 347 .status-muted { 275 348 border-color: var(--color-muted); 276 349 color: var(--color-muted); ··· 282 355 color: var(--color-error); 283 356 background: color-mix(in srgb, var(--color-error) 8%, transparent); 284 357 } 358 + 359 + /* Reusable state cards (loading / empty / error) */ 360 + 361 + .state-card { 362 + display: grid; 363 + align-content: start; 364 + gap: 0.75rem; 365 + min-height: 11rem; 366 + padding: 1rem; 367 + } 368 + 369 + .state-card h2 { 370 + margin: 0; 371 + color: var(--color-emphasis); 372 + letter-spacing: -0.03em; 373 + } 374 + 375 + .state-card p { 376 + margin: 0; 377 + color: var(--color-subtle); 378 + } 379 + 380 + .error-state { 381 + border-color: color-mix(in srgb, var(--color-error) 55%, var(--color-border)); 382 + } 383 + 384 + /* Ordered lists (instructions, print settings) shared across detail + publish */ 385 + 386 + .instruction-list, 387 + .print-settings { 388 + margin: 0; 389 + padding-left: 1.25rem; 390 + color: var(--color-text); 391 + } 392 + 393 + .instruction-list li + li, 394 + .print-settings li + li { 395 + margin-top: 0.35rem; 396 + } 397 + 398 + /* Skeletons */ 285 399 286 400 .skeleton-line, 287 401 .skeleton-box {
+108
assets/styling/home.css
··· 1 + /* Home (discovery browse) + the /foundation dev route. Pages sit inside the 2 + shell content region, so they flex to fill height instead of owning the 3 + viewport with `min-height: 100vh`. */ 4 + 5 + .browse-page { 6 + flex: 1; 7 + min-height: 0; 8 + width: 100%; 9 + max-width: 1180px; 10 + margin: 0 auto; 11 + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 12 + } 13 + 14 + .browse-intro, 15 + .foundation-intro { 16 + display: flex; 17 + flex-direction: column; 18 + gap: 0.35rem; 19 + } 20 + 21 + .browse-intro h1, 22 + .foundation-intro h1 { 23 + margin: 0; 24 + color: var(--color-emphasis); 25 + letter-spacing: -0.03em; 26 + font-size: clamp(2rem, 5vw, 3.25rem); 27 + line-height: 1; 28 + } 29 + 30 + .browse-feed { 31 + display: flex; 32 + flex-direction: column; 33 + gap: 1rem; 34 + } 35 + 36 + .browse-results-grid, 37 + .foundation-grid, 38 + .state-grid { 39 + display: grid; 40 + grid-template-columns: repeat(3, minmax(0, 1fr)); 41 + gap: 1rem; 42 + scroll-margin-top: 1rem; 43 + } 44 + 45 + /* Foundation dev route */ 46 + 47 + .foundation-hero { 48 + display: grid; 49 + grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); 50 + gap: clamp(1rem, 3vw, 2rem); 51 + padding: clamp(1rem, 3vw, 1.5rem); 52 + scroll-margin-top: 1rem; 53 + } 54 + 55 + .foundation-hero h2 { 56 + margin: 0; 57 + color: var(--color-emphasis); 58 + letter-spacing: -0.03em; 59 + } 60 + 61 + .foundation-hero-copy { 62 + display: grid; 63 + align-content: center; 64 + gap: 0.9rem; 65 + } 66 + 67 + .foundation-hero-copy p { 68 + margin: 0; 69 + color: var(--color-subtle); 70 + max-width: 42rem; 71 + } 72 + 73 + .blueprint-preview { 74 + min-height: 18rem; 75 + border: 1px solid var(--color-border); 76 + } 77 + 78 + .blueprint-preview::before, 79 + .blueprint-preview::after { 80 + content: ""; 81 + position: absolute; 82 + inset: 18% 16%; 83 + border: 1px solid var(--color-primary); 84 + } 85 + 86 + .blueprint-preview::after { 87 + inset: 32% 26%; 88 + border-color: var(--color-action); 89 + } 90 + 91 + .foundation-card { 92 + display: grid; 93 + grid-template-rows: auto 1fr; 94 + min-width: 0; 95 + overflow: hidden; 96 + } 97 + 98 + @media (max-width: 820px) { 99 + .foundation-hero { 100 + grid-template-columns: 1fr; 101 + } 102 + 103 + .browse-results-grid, 104 + .foundation-grid, 105 + .state-grid { 106 + grid-template-columns: 1fr; 107 + } 108 + }
+44
assets/styling/placeholders.css
··· 1 + /* Honest placeholder routes: /search and /publish. Reserved surfaces with 2 + clearly-labeled "not built yet" content, inside the shell content region. */ 3 + 4 + .search-page, 5 + .publish-page { 6 + flex: 1; 7 + min-height: 0; 8 + width: 100%; 9 + max-width: 56rem; 10 + margin: 0 auto; 11 + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 12 + display: flex; 13 + flex-direction: column; 14 + gap: 0.9rem; 15 + } 16 + 17 + .search-intro, 18 + .publish-intro, 19 + .publish-signed-out, 20 + .publish-steps { 21 + display: flex; 22 + flex-direction: column; 23 + gap: 0.75rem; 24 + padding: clamp(1.25rem, 3vw, 2rem); 25 + } 26 + 27 + .search-intro h1, 28 + .publish-intro h1 { 29 + margin: 0; 30 + color: var(--color-emphasis); 31 + letter-spacing: -0.03em; 32 + } 33 + 34 + .publish-signed-out h2, 35 + .publish-steps h2 { 36 + margin: 0; 37 + color: var(--color-emphasis); 38 + } 39 + 40 + .search-note { 41 + margin: 0; 42 + color: var(--color-muted); 43 + font-size: 0.9rem; 44 + }
+8 -9
assets/styling/polymodel.css assets/styling/viewer.css
··· 1 + /* Standalone demo viewer route + the retained WebGL model-viewer surface. 2 + Sits inside the shell content region (flex height, not `min-height: 100vh`). */ 3 + 1 4 .viewer-route-page { 2 - min-height: 100vh; 5 + flex: 1; 6 + min-height: 0; 3 7 padding: clamp(1rem, 4vw, 3rem); 4 8 background: var(--color-base); 5 9 color: var(--color-text); 6 - } 7 - 8 - .viewer-route-nav { 9 - margin: 0 auto 1rem; 10 - max-width: 72rem; 11 10 } 12 11 13 12 .model-viewer-shell { ··· 87 86 display: grid; 88 87 grid-template-rows: minmax(24rem, min(56vh, 34rem)) auto; 89 88 background: 90 - linear-gradient(135deg, rgb(255 255 255 / 0.06), transparent 30%), 91 - radial-gradient(circle at 50% 35%, rgb(255 255 255 / 0.12), transparent 24%), 89 + linear-gradient(135deg, var(--overlay-highlight-soft), transparent 30%), 90 + radial-gradient(circle at 50% 35%, var(--overlay-highlight), transparent 24%), 92 91 var(--color-base); 93 92 } 94 93 ··· 114 113 box-sizing: border-box; 115 114 } 116 115 117 - .model-preview-shadow { fill: rgb(0 0 0 / 0.22); } 116 + .model-preview-shadow { fill: var(--overlay-shadow); } 118 117 .model-preview-body { fill: color-mix(in srgb, var(--color-primary) 38%, transparent); stroke: var(--color-primary); stroke-width: 4; } 119 118 .model-preview-cut { fill: color-mix(in srgb, var(--color-base) 80%, transparent); stroke: var(--color-border); stroke-width: 3; } 120 119 .model-preview-line { fill: none; stroke: color-mix(in srgb, var(--color-primary) 70%, var(--color-text)); stroke-width: 3; }
+6 -125
assets/styling/profile.css
··· 1 - /* Profile page: two-column identity rail + content column. 2 - Reuses blueprint tokens (color-*, font-mono, status-pill, tab, state-card, 3 - card-surface, button) from theme/base/browse/cards CSS. */ 1 + /* Profile route: two-column identity rail + content column. Reuses blueprint 2 + primitives (status-pill, tab, state-card, card-surface, button) and the shell 3 + layer for account chrome. Sits inside the shell content region. */ 4 4 5 5 .profile-page { 6 + flex: 1; 7 + min-height: 0; 8 + width: 100%; 6 9 max-width: 1180px; 7 10 margin: 0 auto; 8 11 padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 9 - } 10 - 11 - .profile-nav { 12 - margin-bottom: 0.9rem; 13 - } 14 - 15 - .profile-nav a { 16 - color: var(--color-secondary); 17 - font-size: 0.875rem; 18 12 } 19 13 20 14 .profile-shell { ··· 283 277 margin: 0; 284 278 color: var(--color-emphasis); 285 279 } 286 - 287 - /* Creator links on cards and detail */ 288 - 289 - .thing-card-creator-link, 290 - .thing-detail-creator-link { 291 - color: var(--color-secondary); 292 - } 293 - 294 - .thing-card-creator-link:hover, 295 - .thing-detail-creator-link:hover { 296 - color: var(--color-link); 297 - } 298 - 299 - /* Signed-in account chrome: avatar trigger + click-to-open dropdown menu. */ 300 - 301 - .account-menu { 302 - position: relative; 303 - display: inline-flex; 304 - align-items: center; 305 - } 306 - 307 - .account-menu > summary { 308 - display: inline-flex; 309 - align-items: center; 310 - cursor: pointer; 311 - list-style: none; 312 - } 313 - 314 - .account-menu > summary::-webkit-details-marker { 315 - display: none; 316 - } 317 - 318 - .account-menu > summary:focus-visible { 319 - outline: 2px solid color-mix(in srgb, var(--color-primary) 35%, transparent); 320 - outline-offset: 2px; 321 - } 322 - 323 - .session-avatar { 324 - display: block; 325 - width: 2.12rem; 326 - height: 2.12rem; 327 - object-fit: cover; 328 - border: 1px solid var(--color-border); 329 - flex-shrink: 0; 330 - } 331 - 332 - .session-avatar-fallback { 333 - display: flex; 334 - align-items: center; 335 - justify-content: center; 336 - background-color: var(--color-overlay); 337 - color: var(--color-primary); 338 - font-weight: 700; 339 - font-size: 0.9rem; 340 - } 341 - 342 - .account-menu-panel { 343 - position: absolute; 344 - right: 0; 345 - top: calc(100% + 0.4rem); 346 - z-index: 30; 347 - min-width: 13rem; 348 - display: flex; 349 - flex-direction: column; 350 - gap: 0.1rem; 351 - padding: 0.4rem; 352 - } 353 - 354 - .account-menu-identity { 355 - display: flex; 356 - flex-direction: column; 357 - gap: 0.05rem; 358 - padding: 0.35rem 0.5rem 0.5rem; 359 - margin-bottom: 0.2rem; 360 - border-bottom: 1px dashed var(--color-border); 361 - } 362 - 363 - .account-menu-identity strong { 364 - color: var(--color-text); 365 - } 366 - 367 - .account-menu-handle { 368 - color: var(--color-muted); 369 - font-size: 0.8rem; 370 - word-break: break-word; 371 - } 372 - 373 - .account-menu-form { 374 - margin: 0; 375 - } 376 - 377 - .account-menu-item { 378 - display: block; 379 - width: 100%; 380 - box-sizing: border-box; 381 - padding: 0.4rem 0.5rem; 382 - text-align: left; 383 - background: transparent; 384 - border: 0; 385 - color: var(--color-text); 386 - font-family: var(--font-ui); 387 - font-size: 0.9rem; 388 - cursor: pointer; 389 - } 390 - 391 - .account-menu-item:hover { 392 - background-color: var(--color-overlay); 393 - text-decoration: none; 394 - } 395 - 396 - .account-menu-signout { 397 - color: var(--color-error); 398 - }
+280
assets/styling/shell.css
··· 1 + /* Product shell layer: the persistent global header and the session/account 2 + chrome that now lives in it. The shell owns viewport height; pages are 3 + `flex: 1` inside the content region (see page layers). */ 4 + 5 + .app-shell { 6 + min-height: 100dvh; 7 + display: flex; 8 + flex-direction: column; 9 + } 10 + 11 + .app-header { 12 + border-bottom: 1px solid var(--color-border); 13 + background: var(--color-surface); 14 + } 15 + 16 + .app-header-inner { 17 + display: flex; 18 + align-items: center; 19 + flex-wrap: wrap; 20 + gap: clamp(0.6rem, 2vw, 1.25rem); 21 + width: 100%; 22 + max-width: 1180px; 23 + margin: 0 auto; 24 + padding: 0.6rem clamp(1rem, 3vw, 2.5rem); 25 + } 26 + 27 + .app-wordmark { 28 + font-family: var(--font-heading, var(--font-ui)); 29 + font-weight: 700; 30 + font-size: 1.15rem; 31 + letter-spacing: -0.02em; 32 + color: var(--color-emphasis); 33 + text-decoration: none; 34 + } 35 + 36 + .app-wordmark:hover { 37 + text-decoration: none; 38 + color: var(--color-primary); 39 + } 40 + 41 + .app-nav { 42 + display: flex; 43 + align-items: center; 44 + gap: 0.4rem; 45 + } 46 + 47 + .app-nav-link { 48 + padding: 0.4rem 0.55rem; 49 + border: 1px solid transparent; 50 + border-radius: var(--radius-panel); 51 + color: var(--color-subtle); 52 + font-weight: 600; 53 + } 54 + 55 + .app-nav-link:hover { 56 + text-decoration: none; 57 + border-color: var(--color-primary); 58 + color: var(--color-tertiary); 59 + } 60 + 61 + .app-nav-link.is-active { 62 + border-color: var(--color-primary); 63 + color: var(--color-tertiary); 64 + background: color-mix(in srgb, var(--color-primary) 8%, transparent); 65 + } 66 + 67 + /* Global directed-search field (navigates client-side to /search). */ 68 + .app-search { 69 + display: flex; 70 + align-items: stretch; 71 + flex: 1 1 14rem; 72 + min-width: 8rem; 73 + } 74 + 75 + .app-search-input { 76 + flex: 1; 77 + min-width: 0; 78 + min-height: 2.1rem; 79 + padding: 0.45rem 0.65rem; 80 + border: 1px solid var(--color-border); 81 + border-right: 0; 82 + border-radius: var(--radius-panel) 0 0 var(--radius-panel); 83 + background: var(--color-base); 84 + color: var(--color-text); 85 + font-family: var(--font-ui); 86 + font-size: 0.9rem; 87 + } 88 + 89 + .app-search-input:focus { 90 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 91 + outline-offset: 1px; 92 + border-color: var(--color-primary); 93 + } 94 + 95 + .app-search-button { 96 + min-height: 2.1rem; 97 + border-radius: 0 var(--radius-panel) var(--radius-panel) 0; 98 + } 99 + 100 + .app-publish-cta { 101 + white-space: nowrap; 102 + } 103 + 104 + .app-content { 105 + flex: 1; 106 + min-height: 0; 107 + display: flex; 108 + flex-direction: column; 109 + } 110 + 111 + /* Anonymous sign-in control (header + page-level reuse). */ 112 + .session-control { 113 + display: inline-flex; 114 + align-items: stretch; 115 + gap: 0; 116 + flex-wrap: nowrap; 117 + } 118 + 119 + .session-control input { 120 + width: min(9.5rem, 42vw); 121 + min-height: 2rem; 122 + padding: 0.45rem 0.65rem; 123 + border: 1px solid var(--color-border); 124 + border-right: 0; 125 + border-radius: var(--radius-panel) 0 0 var(--radius-panel); 126 + background: var(--color-surface); 127 + color: var(--color-text); 128 + font-family: var(--font-ui); 129 + font-size: 0.9rem; 130 + } 131 + 132 + .session-control input:focus { 133 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 134 + outline-offset: 1px; 135 + border-color: var(--color-primary); 136 + } 137 + 138 + .session-control .button { 139 + min-height: 2rem; 140 + border-radius: 0 var(--radius-panel) var(--radius-panel) 0; 141 + } 142 + 143 + /* Signed-in account chrome: avatar trigger + click-to-open dropdown menu. */ 144 + 145 + .account-menu { 146 + position: relative; 147 + display: inline-flex; 148 + align-items: center; 149 + } 150 + 151 + .account-menu > summary { 152 + display: inline-flex; 153 + align-items: center; 154 + cursor: pointer; 155 + list-style: none; 156 + } 157 + 158 + .account-menu > summary::-webkit-details-marker { 159 + display: none; 160 + } 161 + 162 + .account-menu > summary:focus-visible { 163 + outline: 2px solid color-mix(in srgb, var(--color-primary) 35%, transparent); 164 + outline-offset: 2px; 165 + } 166 + 167 + .session-avatar { 168 + display: block; 169 + width: 2.12rem; 170 + height: 2.12rem; 171 + object-fit: cover; 172 + border: 1px solid var(--color-border); 173 + flex-shrink: 0; 174 + } 175 + 176 + .session-avatar-fallback { 177 + display: flex; 178 + align-items: center; 179 + justify-content: center; 180 + background-color: var(--color-overlay); 181 + color: var(--color-primary); 182 + font-weight: 700; 183 + font-size: 0.9rem; 184 + } 185 + 186 + .account-menu-panel { 187 + position: absolute; 188 + right: 0; 189 + top: calc(100% + 0.4rem); 190 + z-index: 30; 191 + min-width: 13rem; 192 + display: flex; 193 + flex-direction: column; 194 + gap: 0.1rem; 195 + padding: 0.4rem; 196 + } 197 + 198 + .account-menu-identity { 199 + display: flex; 200 + flex-direction: column; 201 + gap: 0.05rem; 202 + padding: 0.35rem 0.5rem 0.5rem; 203 + margin-bottom: 0.2rem; 204 + border-bottom: 1px dashed var(--color-border); 205 + } 206 + 207 + .account-menu-identity strong { 208 + color: var(--color-text); 209 + } 210 + 211 + .account-menu-handle { 212 + color: var(--color-muted); 213 + font-size: 0.8rem; 214 + word-break: break-word; 215 + } 216 + 217 + .account-menu-form { 218 + margin: 0; 219 + } 220 + 221 + .account-menu-item { 222 + display: block; 223 + width: 100%; 224 + box-sizing: border-box; 225 + padding: 0.4rem 0.5rem; 226 + text-align: left; 227 + background: transparent; 228 + border: 0; 229 + color: var(--color-text); 230 + font-family: var(--font-ui); 231 + font-size: 0.9rem; 232 + cursor: pointer; 233 + } 234 + 235 + .account-menu-item:hover { 236 + background-color: var(--color-overlay); 237 + text-decoration: none; 238 + } 239 + 240 + .account-menu-signout { 241 + color: var(--color-error); 242 + } 243 + 244 + /* Toast shelf (fixed feedback stack). */ 245 + .toast-shelf { 246 + position: fixed; 247 + right: clamp(0.75rem, 3vw, 1.5rem); 248 + bottom: clamp(0.75rem, 3vw, 1.5rem); 249 + z-index: 40; 250 + display: flex; 251 + flex-direction: column; 252 + gap: 0.5rem; 253 + max-width: min(22rem, 90vw); 254 + } 255 + 256 + .toast { 257 + display: flex; 258 + align-items: center; 259 + justify-content: space-between; 260 + gap: 0.75rem; 261 + padding: 0.6rem 0.75rem; 262 + } 263 + 264 + .toast-dismiss { 265 + border: 0; 266 + background: transparent; 267 + color: var(--color-muted); 268 + font-size: 1.1rem; 269 + line-height: 1; 270 + padding: 0 0.25rem; 271 + } 272 + 273 + /* On narrow viewports the directed-search field drops to its own row so the 274 + wordmark, Browse, Publish, and session control all stay visible. */ 275 + @media (max-width: 640px) { 276 + .app-search { 277 + order: 5; 278 + flex-basis: 100%; 279 + } 280 + }
+8
assets/styling/theme.css
··· 79 79 --spacing-line-height: 1.55; 80 80 --spacing-scale: 1.25; 81 81 --radius-panel: 2px; 82 + --radius-pill: 999px; 83 + 84 + /* Scheme-independent lighting overlays for the 3D viewer stage and SVG 85 + preview. Pure white/black at low alpha read as highlight/shadow in both 86 + schemes; defined as tokens so call sites never inline raw rgb(). */ 87 + --overlay-highlight-soft: rgb(255 255 255 / 0.06); 88 + --overlay-highlight: rgb(255 255 255 / 0.12); 89 + --overlay-shadow: rgb(0 0 0 / 0.22); 82 90 } 83 91 84 92 @media (prefers-color-scheme: dark) {
+288
assets/styling/thing-detail.css
··· 1 + /* Thing-detail route: header, viewer well, model/part panels. Sits inside the 2 + shell content region (flex height, not `min-height: 100vh`). */ 3 + 4 + .thing-detail-page { 5 + flex: 1; 6 + min-height: 0; 7 + width: 100%; 8 + max-width: 1180px; 9 + margin: 0 auto; 10 + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 11 + } 12 + 13 + .thing-detail-header, 14 + .thing-detail-summary, 15 + .thing-detail-copy, 16 + .detail-panel, 17 + .selected-model-panel, 18 + .parts-list, 19 + .part-row-body { 20 + display: flex; 21 + flex-direction: column; 22 + } 23 + 24 + .thing-detail-header { 25 + gap: 0.5rem; 26 + } 27 + 28 + .thing-detail-title-row { 29 + display: flex; 30 + justify-content: space-between; 31 + align-items: flex-end; 32 + gap: 1rem; 33 + } 34 + 35 + .thing-detail-title-row h1, 36 + .detail-panel h2, 37 + .selected-model-panel h2, 38 + .part-row h3 { 39 + margin: 0; 40 + color: var(--color-emphasis); 41 + letter-spacing: -0.03em; 42 + } 43 + 44 + .thing-detail-title-row h1 { 45 + font-size: clamp(2.1rem, 5vw, 4rem); 46 + line-height: 0.95; 47 + } 48 + 49 + .thing-detail-creator, 50 + .detail-muted, 51 + .model-row-summary, 52 + .part-notes, 53 + .viewer-unavailable p { 54 + margin: 0; 55 + color: var(--color-subtle); 56 + } 57 + 58 + .thing-detail-stats, 59 + .part-row-meta { 60 + display: flex; 61 + flex-wrap: wrap; 62 + gap: 0.4rem 0.8rem; 63 + margin: 0; 64 + color: var(--color-muted); 65 + font-family: var(--font-mono); 66 + font-size: 0.76rem; 67 + } 68 + 69 + .thing-detail-stats div, 70 + .part-row-meta div { 71 + display: contents; 72 + } 73 + 74 + .thing-detail-stats dt, 75 + .part-row-meta dt { 76 + position: absolute; 77 + width: 1px; 78 + height: 1px; 79 + padding: 0; 80 + overflow: hidden; 81 + clip: rect(0, 0, 0, 0); 82 + white-space: nowrap; 83 + border: 0; 84 + } 85 + 86 + .thing-detail-stats dd, 87 + .part-row-meta dd { 88 + margin: 0; 89 + } 90 + 91 + .thing-detail-shell { 92 + display: grid; 93 + grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); 94 + align-items: start; 95 + gap: clamp(1rem, 3vw, 1.5rem); 96 + padding: clamp(1rem, 3vw, 1.5rem); 97 + } 98 + 99 + .thing-detail-viewer { 100 + aspect-ratio: 4 / 3; 101 + min-height: 20rem; 102 + max-height: 34rem; 103 + border: 1px solid var(--color-border); 104 + border-radius: var(--radius-panel); 105 + overflow: hidden; 106 + } 107 + 108 + .detail-asset-viewer, 109 + .detail-asset-viewer .model-viewer-boundary, 110 + .detail-asset-viewer .model-viewer-stage { 111 + height: 100%; 112 + min-height: 0; 113 + } 114 + 115 + .detail-asset-viewer .model-viewer-boundary { 116 + border: 0; 117 + background: transparent; 118 + } 119 + 120 + .detail-asset-viewer .model-viewer-stage { 121 + grid-template-rows: 1fr; 122 + } 123 + 124 + .model-viewer-preview-image { 125 + grid-row: 1; 126 + grid-column: 1; 127 + width: 100%; 128 + height: 100%; 129 + object-fit: cover; 130 + } 131 + 132 + .thing-detail-summary { 133 + justify-content: space-between; 134 + gap: 1rem; 135 + } 136 + 137 + .thing-detail-copy { 138 + gap: 0.85rem; 139 + } 140 + 141 + .thing-detail-chips, 142 + .thing-detail-actions { 143 + display: flex; 144 + flex-wrap: wrap; 145 + gap: 0.5rem; 146 + } 147 + 148 + .thing-detail-actions { 149 + align-items: center; 150 + } 151 + 152 + .thing-detail-actions .button-primary { 153 + min-width: 9.5rem; 154 + } 155 + 156 + .model-row:disabled, 157 + .model-row[disabled] { 158 + cursor: not-allowed; 159 + opacity: 0.52; 160 + filter: saturate(0.7); 161 + } 162 + 163 + .thing-detail-grid { 164 + display: grid; 165 + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); 166 + gap: 1rem; 167 + align-items: start; 168 + } 169 + 170 + .detail-panel { 171 + gap: 0.85rem; 172 + padding: 1rem; 173 + } 174 + 175 + .thing-detail-grid > .detail-panel:nth-child(-n + 2) { 176 + align-self: stretch; 177 + height: 100%; 178 + } 179 + 180 + .detail-panel-heading { 181 + display: flex; 182 + align-items: baseline; 183 + } 184 + 185 + .detail-panel-heading h2, 186 + .selected-model-heading h2 { 187 + font-size: clamp(1.25rem, 2vw, 1.6rem); 188 + line-height: 1.1; 189 + } 190 + 191 + .selected-model-heading { 192 + display: flex; 193 + flex-direction: column; 194 + gap: 0.3rem; 195 + } 196 + 197 + .model-list { 198 + display: flex; 199 + flex-direction: column; 200 + gap: 0.55rem; 201 + } 202 + 203 + .model-row { 204 + display: flex; 205 + flex-direction: column; 206 + gap: 0.25rem; 207 + width: 100%; 208 + padding: 0.75rem; 209 + border: 1px solid var(--color-border); 210 + border-radius: var(--radius-panel); 211 + background: var(--color-base); 212 + color: var(--color-text); 213 + text-align: left; 214 + } 215 + 216 + .model-row:hover, 217 + .model-row.is-selected { 218 + border-color: var(--color-primary); 219 + } 220 + 221 + .model-row.is-selected { 222 + background: color-mix(in srgb, var(--color-primary) 8%, transparent); 223 + } 224 + 225 + .model-row-title { 226 + font-weight: 700; 227 + } 228 + 229 + .model-row-meta { 230 + color: var(--color-muted); 231 + font-family: var(--font-mono); 232 + font-size: 0.75rem; 233 + } 234 + 235 + .selected-model-panel, 236 + .part-row-body { 237 + gap: 0.65rem; 238 + } 239 + 240 + .parts-list { 241 + gap: 0.75rem; 242 + } 243 + 244 + .part-row { 245 + display: flex; 246 + gap: 0.75rem; 247 + padding: 0.75rem; 248 + border: 1px solid var(--color-border); 249 + border-radius: var(--radius-panel); 250 + background: var(--color-base); 251 + } 252 + 253 + .part-row-index { 254 + display: inline-flex; 255 + align-items: center; 256 + justify-content: center; 257 + flex: 0 0 2rem; 258 + width: 2rem; 259 + height: 2rem; 260 + border: 1px solid var(--color-primary); 261 + border-radius: var(--radius-pill); 262 + color: var(--color-primary); 263 + font-family: var(--font-mono); 264 + font-size: 0.78rem; 265 + } 266 + 267 + .viewer-unavailable { 268 + min-height: 12rem; 269 + padding: 1rem; 270 + border: 1px solid var(--color-border); 271 + border-radius: var(--radius-panel); 272 + display: flex; 273 + flex-direction: column; 274 + justify-content: space-between; 275 + gap: 1rem; 276 + } 277 + 278 + @media (max-width: 820px) { 279 + .thing-detail-shell, 280 + .thing-detail-grid { 281 + grid-template-columns: 1fr; 282 + } 283 + 284 + .thing-detail-title-row { 285 + display: grid; 286 + align-items: start; 287 + } 288 + }
+53 -7
e2e/tests/home.spec.ts
··· 1 1 import { expect, test } from '@playwright/test'; 2 2 3 - test('home page shows the product browse shell with reusable thing cards', async ({ page }) => { 3 + test('home page shows the product shell and discovery feed with reusable thing cards', async ({ page }) => { 4 4 await page.goto('/'); 5 5 6 - await expect(page.getByRole('heading', { name: 'Polymodel' })).toBeVisible(); 7 - await expect(page.getByLabel('Browse shell')).toBeVisible(); 8 - await expect(page.getByRole('tab', { name: 'Recent' })).toBeVisible(); 9 - await expect(page.getByRole('tab', { name: 'Hot' })).toBeVisible(); 10 - await expect(page.getByRole('searchbox', { name: 'Search model projects' })).toBeVisible(); 6 + // Persistent product shell. 7 + await expect(page.getByRole('link', { name: 'Polymodel home' })).toBeVisible(); 8 + await expect(page.getByRole('searchbox', { name: 'Search Polymodel' })).toBeVisible(); 9 + await expect(page.getByRole('link', { name: 'Publish' })).toBeVisible(); 10 + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); 11 + 12 + // Discovery home heading + feed selector (Hot default, Following disabled). 13 + await expect(page.getByRole('heading', { name: 'Browse the catalog' })).toBeVisible(); 14 + await expect(page.getByRole('tab', { name: 'Hot' })).toHaveAttribute('aria-selected', 'true'); 15 + await expect(page.getByRole('tab', { name: 'Recent' })).toHaveAttribute('aria-selected', 'false'); 16 + await expect(page.getByRole('tab', { name: 'Following' })).toBeDisabled(); 17 + 18 + // No inline catalog search box on the home surface anymore. 19 + await expect(page.getByRole('searchbox', { name: 'Search model projects' })).toHaveCount(0); 11 20 12 21 const results = page.getByLabel('Browse results'); 13 22 await expect(results).toBeVisible(); ··· 19 28 await expect(page.getByText('18 parts')).toBeVisible(); 20 29 await expect(page.getByRole('img', { name: 'Rendered preview of a parametric electronics enclosure' })).toBeVisible(); 21 30 22 - 23 31 await expect(page.getByRole('heading', { name: 'Untitled thing' })).toBeVisible(); 24 32 await expect(page.getByText('by mira.tools')).toBeVisible(); 25 33 ··· 30 38 31 39 await expect(page.getByLabel('Browse results placeholder')).toHaveCount(0); 32 40 await expect(page.getByText('Empty')).toHaveCount(0); 41 + }); 42 + 43 + test('global shell search navigates client-side to /search', async ({ page }) => { 44 + await page.goto('/'); 45 + 46 + // Gate on a rendered feed card so the wasm client has hydrated and wired the 47 + // search field's keydown handler before we type — otherwise Enter races 48 + // hydration and is dropped (the SSR input accepts fill but no handler runs). 49 + await expect(page.getByLabel('Browse results').getByRole('article').first()).toBeVisible(); 50 + 51 + const search = page.getByRole('searchbox', { name: 'Search Polymodel' }); 52 + await search.fill('enclosure'); 53 + await search.press('Enter'); 54 + 55 + await expect(page).toHaveURL(/\/search\?q=enclosure$/); 56 + await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); 57 + await expect(page.getByText('Not wired up yet')).toBeVisible(); 58 + await expect(page.getByText(/Searched for/)).toBeVisible(); 59 + }); 60 + 61 + test('shell header is present on a deep route', async ({ page }) => { 62 + await page.goto('/profile'); 63 + 64 + await expect(page.getByRole('link', { name: 'Polymodel home' })).toBeVisible(); 65 + await expect(page.getByRole('searchbox', { name: 'Search Polymodel' })).toBeVisible(); 66 + await expect(page.getByRole('link', { name: 'Publish' })).toBeVisible(); 67 + }); 68 + 69 + test('placeholder routes resolve with honest, non-faked content', async ({ page }) => { 70 + await page.goto('/search'); 71 + await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); 72 + await expect(page.getByText('Not wired up yet')).toBeVisible(); 73 + 74 + await page.goto('/publish'); 75 + await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible(); 76 + await expect(page.getByText('Coming soon')).toBeVisible(); 77 + // Anonymous publish prompts sign-in (returns the user to /publish). 78 + await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible(); 33 79 }); 34 80 35 81 test('home thing cards remain readable at narrow widths', async ({ page }) => {
+11 -12
e2e/tests/viewer.spec.ts
··· 18 18 await expect(page.locator(preview)).toHaveCount(0); 19 19 } 20 20 21 - test('viewer is reachable from browse and displays the committed STL asset', async ({ page }) => { 22 - await page.goto('/'); 23 - await page.getByRole('link', { name: 'Open viewer' }).click(); 21 + test('the demo viewer route displays the committed STL asset inside the shell', async ({ page }) => { 22 + await page.goto('/viewer/body-f-chest-v4'); 24 23 25 24 await expect(page).toHaveURL(/\/viewer\/body-f-chest-v4$/); 26 25 await expectViewerReady(page); 26 + // The product shell wraps the demo viewer route (reachable by direct URL only). 27 + await expect(page.getByRole('link', { name: 'Polymodel home' })).toBeVisible(); 28 + // The selected model and ready status surface on the boundary's data attributes 29 + // and in the visible header chrome (status pill + active model control). 27 30 await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); 28 31 await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', chestAssetPath); 29 - await expect(page.locator(boundary)).toContainText('Body F chest v4'); 30 - await expect(page.locator(boundary)).toContainText(chestAssetPath); 31 - await expect(page.locator(boundary)).toContainText('41051'); 32 - await expect(page.locator(boundary)).toContainText('82122'); 33 - await expect(page.locator(boundary)).toContainText('Viewer ready'); 32 + await expect(page.locator(boundary)).toHaveAttribute('data-viewer-status', 'Viewer ready'); 33 + await expect(page.getByText('Viewer ready')).toBeVisible(); 34 + await expect(page.getByRole('button', { name: 'Body F chest v4' })).toBeVisible(); 34 35 }); 35 36 36 37 test('direct viewer routes select deterministic asset-backed models', async ({ page }) => { ··· 38 39 await expectViewerReady(page); 39 40 await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-chest-v4#1'); 40 41 await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', chestAssetPath); 41 - await expect(page.locator(boundary)).toContainText('Body F chest v4'); 42 + await expect(page.getByRole('button', { name: 'Body F chest v4' })).toBeVisible(); 42 43 43 44 await page.goto('/viewer/body-f-forearm-2x-v9'); 44 45 await expectViewerReady(page); 45 46 await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-forearm-2x-v9#1'); 46 47 await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); 47 - await expect(page.locator(boundary)).toContainText('Body F forearm 2x v9'); 48 + await expect(page.getByRole('button', { name: 'Body F forearm 2x v9' })).toBeVisible(); 48 49 }); 49 50 50 51 test('model switching keeps one WebGL context and updates renderer session metadata', async ({ page }) => { ··· 59 60 await expect(page.locator(canvas)).toHaveCount(1); 60 61 await expect(page.locator(boundary)).toHaveAttribute('data-session-label', 'body-f-forearm-2x-v9#1'); 61 62 await expect(page.locator(boundary)).toHaveAttribute('data-asset-path', forearmAssetPath); 62 - await expect(page.locator(boundary)).toContainText('18271'); 63 - await expect(page.locator(boundary)).toContainText('36542'); 64 63 // Switching models must NOT acquire a second WebGL context (in-place replace). 65 64 await expect.poll(acquisitions.bind(null, page)).toBe(1); 66 65
+37 -57
src/browse.rs
··· 2 2 use jacquard_common::xrpc::XrpcClient; 3 3 use polymodel_api::space_polymodel::library; 4 4 5 - use crate::Route; 6 - use crate::auth::ToastState; 7 5 use crate::client::PolymodelClient; 8 6 use crate::profile::profile_href; 9 - use crate::session::SessionIdentity; 10 - use crate::shell::SessionControl; 11 7 use crate::thing_card::ThingCard; 12 8 use crate::thing_detail::thing_detail_href; 13 - use crate::viewer::demo_models; 14 9 15 10 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 16 11 enum FeedAlgorithm { ··· 61 56 #[allow(clippy::useless_format)] 62 57 #[component] 63 58 pub(crate) fn Browse() -> Element { 64 - let viewer_model = demo_models()[0].id.to_string(); 65 59 let client = use_context::<PolymodelClient>(); 66 - let session = use_context::<Signal<SessionIdentity>>(); 67 - let toasts = use_context::<Signal<ToastState>>(); 68 - let selected_algorithm = use_signal(|| FeedAlgorithm::Recent); 60 + let selected_algorithm = use_signal(|| FeedAlgorithm::Hot); 69 61 70 62 let mut feed = use_resource(move || { 71 63 let client = client.clone(); ··· 95 87 let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); 96 88 97 89 rsx! { 98 - main { class: "browse-page product-shell", 99 - header { class: "product-header", 100 - div { class: "product-kicker", "Show off your creations" } 101 - div { class: "product-header-row", 102 - div { h1 { "Polymodel" } } 103 - nav { class: "product-nav", aria_label: "Primary", 104 - a { href: "#browse-shell", "Browse" } 105 - Link { to: Route::Foundation {}, "Foundation" } 106 - Link { class: "button button-secondary", to: Route::Viewer { model_id: viewer_model }, "Open viewer" } 107 - SessionControl { session, toasts } 108 - } 109 - } 90 + main { class: "browse-page", 91 + header { class: "browse-intro", 92 + h1 { "Browse the catalog" } 93 + p { class: "product-lede", "Discover printable models and projects shared on ATProto. Pick a feed to start exploring." } 110 94 } 111 95 112 - section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", 113 - label { class: "search-field", 114 - span { "Go find something" } 115 - input { r#type: "search", placeholder: "Search by project, tag, or maker", aria_label: "Search model projects" } 116 - } 96 + section { class: "browse-feed", aria_label: "Catalog feed", 117 97 div { class: "tab-row", role: "tablist", aria_label: "Feeds", 118 - FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } 119 98 FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } 99 + FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } 120 100 button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } 121 101 } 122 - } 123 102 124 - section { class: "browse-results-grid", aria_label: "Browse results", 125 - match feed_state { 126 - BrowseFeedState::Loading => rsx! { 127 - for index in 0..6 { 128 - div { key: "skeleton-{index}", class: "state-card loading-state", 129 - span { class: "status-pill", "Loading" } 130 - div { class: "skeleton-line skeleton-wide" } 131 - div { class: "skeleton-line" } 132 - div { class: "skeleton-box" } 103 + section { class: "browse-results-grid", aria_label: "Browse results", 104 + match feed_state { 105 + BrowseFeedState::Loading => rsx! { 106 + for index in 0..6 { 107 + div { key: "skeleton-{index}", class: "state-card loading-state", 108 + span { class: "status-pill", "Loading" } 109 + div { class: "skeleton-line skeleton-wide" } 110 + div { class: "skeleton-line" } 111 + div { class: "skeleton-box" } 112 + } 133 113 } 134 - } 135 - }, 136 - BrowseFeedState::Error(error) => rsx! { 137 - div { class: "state-card error-state browse-state-card", 138 - p { "{error}" } 139 - button { class: "button button-secondary", onclick: move |_| feed.restart(), "Retry" } 140 - } 141 - }, 142 - BrowseFeedState::Empty => rsx! { 143 - div { class: "state-card empty-state browse-state-card", 144 - span { class: "status-pill status-muted", "Empty" } 145 - h2 { "No projects yet" } 146 - p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } 147 - } 148 - }, 149 - BrowseFeedState::Populated(things) => rsx! { 150 - for thing in things { 151 - BrowseThingCard { key: "{thing.uri}", thing } 152 - } 153 - }, 114 + }, 115 + BrowseFeedState::Error(error) => rsx! { 116 + div { class: "state-card error-state browse-state-card", 117 + p { "{error}" } 118 + button { class: "button button-secondary", onclick: move |_| feed.restart(), "Retry" } 119 + } 120 + }, 121 + BrowseFeedState::Empty => rsx! { 122 + div { class: "state-card empty-state browse-state-card", 123 + span { class: "status-pill status-muted", "Empty" } 124 + h2 { "No projects yet" } 125 + p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } 126 + } 127 + }, 128 + BrowseFeedState::Populated(things) => rsx! { 129 + for thing in things { 130 + BrowseThingCard { key: "{thing.uri}", thing } 131 + } 132 + }, 133 + } 154 134 } 155 135 } 156 136 }
+3 -14
src/foundation.rs
··· 1 1 use dioxus::prelude::*; 2 2 3 - use crate::Route; 4 - 5 3 #[component] 6 4 pub(crate) fn Foundation() -> Element { 7 5 rsx! { 8 6 main { class: "browse-page product-shell", 9 - header { class: "product-header", 10 - div { class: "product-kicker", "Front-end foundation" } 11 - div { class: "product-header-row", 12 - div { 13 - h1 { "Foundation" } 14 - p { class: "product-lede", "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." } 15 - } 16 - nav { class: "product-nav", aria_label: "Foundation navigation", 17 - Link { to: Route::Browse {}, "Back to home" } 18 - a { href: "#ui-states", "UI states" } 19 - } 20 - } 7 + header { class: "foundation-intro", 8 + h1 { "Foundation" } 9 + p { class: "product-lede", "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." } 21 10 } 22 11 23 12 section { id: "browse-foundation", class: "foundation-hero blueprint-panel",
+96 -1
src/indexing/sample_data.rs
··· 23 23 const SAMPLE_TIME: &str = "2026-06-21T12:00:00.000Z"; 24 24 const SAMPLE_TIME_MILLIS: i64 = 1_750_507_200_000; 25 25 26 + // Additional demo makers so the discovery home shows a populated feed with 27 + // mixed media states: one thing with a cover, two cover-less things that 28 + // render the missing-media blueprint placeholder. 29 + const MIRA_DID: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; 30 + const MIRA_HANDLE: &str = "mira.tools"; 31 + const FOUNDRY_DID: &str = "did:plc:cccccccccccccccccccccccc"; 32 + const FOUNDRY_HANDLE: &str = "foundrylab.tools"; 33 + 26 34 pub async fn seed_if_empty(pool: &SqlitePool) -> anyhow::Result<()> { 27 35 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM things") 28 36 .fetch_one(pool) ··· 141 149 ) 142 150 .bind(SAMPLE_THING_URI) 143 151 .execute(&mut *tx) 152 + .await?; 153 + 154 + // Two more makers with cover-less things so the home feed is populated and 155 + // the missing-media placeholder path renders against real projected rows. 156 + insert_identity(&mut tx, MIRA_DID, MIRA_HANDLE).await?; 157 + insert_identity(&mut tx, FOUNDRY_DID, FOUNDRY_HANDLE).await?; 158 + insert_coverless_thing( 159 + &mut tx, 160 + MIRA_DID, 161 + "field-prototype", 162 + "Untitled thing", 163 + "A work-in-progress upload with no preview rendered yet.", 164 + 0, 165 + 0, 166 + ) 167 + .await?; 168 + insert_coverless_thing( 169 + &mut tx, 170 + FOUNDRY_DID, 171 + "modular-calibration-tower", 172 + "Modular calibration tower", 173 + "A calibration tower for dialing in printer tolerances before a full run.", 174 + 8, 175 + 5, 176 + ) 144 177 .await?; 145 178 146 179 tx.commit().await?; ··· 311 344 .collect() 312 345 } 313 346 347 + async fn insert_coverless_thing( 348 + conn: &mut SqliteConnection, 349 + did: &str, 350 + rkey: &str, 351 + name: &str, 352 + summary: &str, 353 + like_count: i64, 354 + save_count: i64, 355 + ) -> anyhow::Result<()> { 356 + let uri = thing_uri(did, rkey); 357 + let record = typed_json::<Thing>(json!({ 358 + "name": name, 359 + "summary": summary, 360 + "license": "CC-BY-4.0", 361 + "createdAt": SAMPLE_TIME 362 + }))?; 363 + 364 + sqlx::query( 365 + r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json, 366 + tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json) 367 + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)"#, 368 + ) 369 + .bind(did) 370 + .bind(rkey) 371 + .bind(&uri) 372 + .bind(format!("bafyreithing{}", rkey.replace('-', ""))) 373 + .bind(name) 374 + .bind(summary) 375 + .bind("CC-BY-4.0") 376 + .bind(SAMPLE_TIME_MILLIS) 377 + .bind(SAMPLE_TIME_MILLIS) 378 + .bind(record) 379 + .execute(&mut *conn) 380 + .await?; 381 + 382 + if like_count > 0 || save_count > 0 { 383 + sqlx::query( 384 + "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, ?, ?, 0)", 385 + ) 386 + .bind(&uri) 387 + .bind(like_count) 388 + .bind(save_count) 389 + .execute(&mut *conn) 390 + .await?; 391 + } 392 + Ok(()) 393 + } 394 + 314 395 fn model_uri(rkey: &str) -> String { 315 396 format!("at://{SAMPLE_DID}/space.polymodel.library.model/{rkey}") 316 397 } 317 398 318 399 fn part_uri(rkey: &str) -> String { 319 400 format!("at://{SAMPLE_DID}/space.polymodel.library.part/{rkey}") 401 + } 402 + 403 + fn thing_uri(did: &str, rkey: &str) -> String { 404 + format!("at://{did}/space.polymodel.library.thing/{rkey}") 320 405 } 321 406 322 407 fn strong_ref(uri: &str) -> Value { ··· 383 468 let state = Arc::new(AppState::new(pool, bootstrap)); 384 469 385 470 let feed = views::feed_recent(&state, 10, None, None).await.unwrap(); 386 - assert_eq!(feed.items.len(), 1); 471 + // Three seeded things: the model-rich parametric kit plus two cover-less 472 + // demo things that exercise the missing-media placeholder path. 473 + assert_eq!(feed.items.len(), 3); 474 + // feed_recent is rkey DESC, so "parametric-enclosure" sorts first. 387 475 assert_eq!( 388 476 feed.items[0].thing.name.as_str(), 389 477 "Parametric enclosure kit" 390 478 ); 391 479 assert_eq!(feed.items[0].thing.model_count, 3); 392 480 assert_eq!(feed.items[0].thing.part_count, 18); 481 + let names: Vec<&str> = feed 482 + .items 483 + .iter() 484 + .map(|item| item.thing.name.as_str()) 485 + .collect(); 486 + assert!(names.contains(&"Untitled thing")); 487 + assert!(names.contains(&"Modular calibration tower")); 393 488 394 489 let (thing, models) = views::get_thing(&state, SAMPLE_THING_URI, None) 395 490 .await
+27 -10
src/main.rs
··· 9 9 mod foundation; 10 10 mod mesh; 11 11 mod profile; 12 + mod publish; 13 + mod search; 12 14 mod session; 13 15 mod shell; 14 16 mod thing_card; ··· 31 33 use client::PolymodelClient; 32 34 use foundation::Foundation; 33 35 use profile::{Profile, ProfileSelf}; 36 + use publish::Publish; 37 + use search::Search; 34 38 use session::SessionIdentity; 35 - use shell::ToastShelf; 39 + use shell::{AppShell, ToastShelf}; 36 40 use thing_detail::ThingDetail; 37 41 use viewer_route::Viewer; 38 42 39 43 const FAVICON: Asset = asset!("/assets/favicon.jpg"); 40 44 const THEME_CSS: Asset = asset!("/assets/styling/theme.css"); 41 45 const BASE_CSS: Asset = asset!("/assets/styling/base.css"); 42 - const CARDS_CSS: Asset = asset!("/assets/styling/cards.css"); 43 - const BROWSE_CSS: Asset = asset!("/assets/styling/browse.css"); 44 - const POLYMODEL_CSS: Asset = asset!("/assets/styling/polymodel.css"); 46 + const PRIMITIVES_CSS: Asset = asset!("/assets/styling/primitives.css"); 47 + const SHELL_CSS: Asset = asset!("/assets/styling/shell.css"); 48 + const HOME_CSS: Asset = asset!("/assets/styling/home.css"); 49 + const THING_DETAIL_CSS: Asset = asset!("/assets/styling/thing-detail.css"); 45 50 const PROFILE_CSS: Asset = asset!("/assets/styling/profile.css"); 51 + const VIEWER_CSS: Asset = asset!("/assets/styling/viewer.css"); 52 + const PLACEHOLDERS_CSS: Asset = asset!("/assets/styling/placeholders.css"); 46 53 47 54 #[cfg(feature = "server")] 48 55 fn main() { ··· 68 75 .await 69 76 .expect("failed to seed local/demo sample data"); 70 77 } 71 - indexing::start_indexing(db.clone()) 72 - .await 73 - .expect("failed to start indexing pipeline"); 78 + // Indexing is a soft dependency; serve read-only from the projection if 79 + // the firehose can't start (e.g. the hydrant DB is locked by another dev server). 80 + if let Err(e) = indexing::start_indexing(db.clone()).await { 81 + tracing::error!("indexing pipeline disabled, serving from existing projection: {e:#}"); 82 + } 74 83 let oauth = oauth::bootstrap_oauth(db.clone(), cfg.base_url.as_deref()) 75 84 .expect("failed to bootstrap OAuth client"); 76 85 // The same SQLite pool backs both indexing and the appview read handlers. ··· 102 111 103 112 #[derive(Clone, Debug, PartialEq, Routable)] 104 113 pub(crate) enum Route { 114 + #[layout(AppShell)] 105 115 #[route("/")] 106 116 Browse {}, 107 117 #[route("/foundation")] 108 118 Foundation {}, 119 + #[route("/search?:q")] 120 + Search { q: String }, 121 + #[route("/publish")] 122 + Publish {}, 109 123 #[route("/profile")] 110 124 ProfileSelf {}, 111 125 #[route("/profile/:actor")] ··· 142 156 document::Link { rel: "icon", href: FAVICON } 143 157 Stylesheet { href: THEME_CSS } 144 158 Stylesheet { href: BASE_CSS } 145 - Stylesheet { href: CARDS_CSS } 146 - Stylesheet { href: BROWSE_CSS } 147 - Stylesheet { href: POLYMODEL_CSS } 159 + Stylesheet { href: PRIMITIVES_CSS } 160 + Stylesheet { href: SHELL_CSS } 161 + Stylesheet { href: HOME_CSS } 162 + Stylesheet { href: THING_DETAIL_CSS } 148 163 Stylesheet { href: PROFILE_CSS } 164 + Stylesheet { href: VIEWER_CSS } 165 + Stylesheet { href: PLACEHOLDERS_CSS } 149 166 150 167 Router::<Route> {} 151 168 ToastShelf { toasts }
+8 -11
src/profile.rs
··· 230 230 pub(crate) fn ProfileSelf() -> Element { 231 231 let client = use_context::<PolymodelClient>(); 232 232 let session = use_context::<Signal<SessionIdentity>>(); 233 + // Sign-in must return the user to the page they started on; here that is 234 + // the current profile route rather than a hardcoded path. 235 + let return_to = use_route::<Route>().to_string(); 233 236 // Re-check the session on mount — the App-level seed runs once and may have 234 237 // failed or not resolved yet. This ensures /profile always has a fresh read. 235 238 let session_check = use_resource(move || { ··· 256 259 if session_check.pending() { 257 260 return rsx! { 258 261 main { class: "profile-page product-shell", 259 - nav { class: "profile-nav", aria_label: "Profile navigation", 260 - Link { to: Route::Browse {}, "← Back to browse" } 261 - } 262 262 section { class: "profile-shell blueprint-panel", 263 + h1 { class: "sr-only", "Loading profile" } 263 264 div { class: "state-card loading-state", 264 265 span { class: "status-pill", "Loading" } 265 266 div { class: "skeleton-line skeleton-wide" } ··· 277 278 } 278 279 SessionIdentity::Anonymous => rsx! { 279 280 main { class: "profile-page product-shell", 280 - nav { class: "profile-nav", aria_label: "Profile navigation", 281 - Link { to: Route::Browse {}, "← Back to browse" } 282 - } 283 281 section { class: "profile-sign-in blueprint-panel", 284 282 span { class: "status-pill", "Signed out" } 285 283 h1 { "Your profile" } ··· 288 286 class: "session-control", 289 287 action: "/oauth/start", 290 288 method: "get", 291 - input { r#type: "hidden", name: "return_to", value: "/profile" }, 289 + input { r#type: "hidden", name: "return_to", value: "{return_to}" }, 292 290 input { 293 291 r#type: "text", 294 292 name: "identifier", ··· 321 319 322 320 rsx! { 323 321 main { class: "profile-page product-shell", 324 - nav { class: "profile-nav", aria_label: "Profile navigation", 325 - Link { to: Route::Browse {}, "← Back to browse" } 326 - } 327 322 match state { 328 323 ProfileState::Loading => rsx! { ProfileLoading {} }, 329 324 ProfileState::InvalidActor(message) => rsx! { ··· 370 365 fn ProfileLoading() -> Element { 371 366 rsx! { 372 367 section { class: "profile-shell blueprint-panel", aria_label: "Profile loading", 368 + h1 { class: "sr-only", "Loading profile" } 373 369 div { class: "state-card loading-state", 374 370 span { class: "status-pill", "Loading" } 375 371 div { class: "skeleton-line skeleton-wide" } ··· 440 436 active_tab: Signal<ProfileTab>, 441 437 ) -> Element { 442 438 let header = data.header; 439 + let return_to = use_route::<Route>().to_string(); 443 440 let things = data.things; 444 441 let display_label = header_display_label(&header); 445 442 let handle_str = header.handle.as_ref().to_string(); ··· 502 499 button { class: "button button-secondary", disabled: true, "Edit profile" } 503 500 } else if is_anonymous { 504 501 form { class: "profile-follow-form", action: "/oauth/start", method: "get", 505 - input { r#type: "hidden", name: "return_to", value: "/profile/{handle_str}" } 502 + input { r#type: "hidden", name: "return_to", value: "{return_to}" } 506 503 button { class: "button button-primary", r#type: "submit", "Sign in to follow" } 507 504 } 508 505 } else {
+55
src/publish.rs
··· 1 + use dioxus::prelude::*; 2 + 3 + use crate::Route; 4 + use crate::session::SessionIdentity; 5 + 6 + /// Honest placeholder for the publish flow. The wizard (PM-43) is not built 7 + /// yet, so this route reserves the surface and prompts anonymous users to sign 8 + /// in with a `return_to` of `/publish` — never a faked upload form. 9 + #[allow(clippy::useless_format)] 10 + #[component] 11 + pub(crate) fn Publish() -> Element { 12 + let session = use_context::<Signal<SessionIdentity>>(); 13 + let return_to = use_route::<Route>().to_string(); 14 + let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous); 15 + 16 + rsx! { 17 + main { class: "publish-page", 18 + section { class: "publish-intro blueprint-panel", aria_label: "Publish", 19 + span { class: "status-pill status-muted", "Coming soon" } 20 + h1 { "Publish a thing" } 21 + 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." } 22 + } 23 + 24 + if is_anonymous { 25 + section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in to publish", 26 + span { class: "status-pill", "Signed out" } 27 + h2 { "Sign in to publish" } 28 + p { "Publishing needs an ATProto account. Sign in now and you return here, ready for when the flow ships." } 29 + form { 30 + class: "session-control", 31 + action: "/oauth/start", 32 + method: "get", 33 + input { r#type: "hidden", name: "return_to", value: "{return_to}" }, 34 + input { 35 + r#type: "text", 36 + name: "identifier", 37 + placeholder: "handle.test", 38 + aria_label: "ATProto handle for publishing", 39 + } 40 + button { class: "button button-primary", r#type: "submit", "Sign in" } 41 + } 42 + } 43 + } else { 44 + section { class: "publish-steps blueprint-panel", aria_label: "Publish outline", 45 + h2 { "What the flow will cover" } 46 + ol { class: "instruction-list", 47 + li { "Describe the thing: title, summary, tags, and license." } 48 + li { "Add models and upload their ordered parts and files." } 49 + li { "Review, then publish to your ATProto repository." } 50 + } 51 + } 52 + } 53 + } 54 + } 55 + }
+28
src/search.rs
··· 1 + use dioxus::prelude::*; 2 + 3 + use crate::Route; 4 + 5 + /// Honest placeholder for directed catalog search. The global shell search 6 + /// field routes here with `?q=`. Search indexing and filtering are not built 7 + /// yet, so this page captures the query without ever fabricating results. 8 + #[allow(clippy::useless_format)] 9 + #[component] 10 + pub(crate) fn Search(q: String) -> Element { 11 + let query = q.trim().to_string(); 12 + 13 + rsx! { 14 + main { class: "search-page", 15 + section { class: "search-intro blueprint-panel", aria_label: "Search", 16 + span { class: "status-pill status-muted", "Not wired up yet" } 17 + h1 { "Search" } 18 + if query.is_empty() { 19 + p { class: "product-lede", "Directed catalog search is on the way. Run a query from the header and it will be captured here." } 20 + } else { 21 + 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." } 22 + } 23 + p { class: "search-note", "Until search ships, browse the catalog from the home feed." } 24 + Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } 25 + } 26 + } 27 + } 28 + }
+81 -1
src/shell.rs
··· 4 4 use crate::auth::{ToastState, broadcast_sign_out, logout_url}; 5 5 use crate::session::SessionIdentity; 6 6 7 + /// Global product shell. Renders the persistent header (wordmark, primary nav, 8 + /// global directed-search field, Publish CTA, and session/account control) once 9 + /// and hosts every route through the router `Outlet`. Pages render their own 10 + /// `<main>`/`<h1>` inside the outlet, so the shell deliberately uses a `<div>` 11 + /// content region rather than a competing `<main>`. 12 + #[component] 13 + pub(crate) fn AppShell() -> Element { 14 + let session = use_context::<Signal<SessionIdentity>>(); 15 + let toasts = use_context::<Signal<ToastState>>(); 16 + 17 + rsx! { 18 + div { class: "app-shell", 19 + header { class: "app-header", 20 + div { class: "app-header-inner", 21 + Link { 22 + class: "app-wordmark", 23 + to: Route::Browse {}, 24 + aria_label: "Polymodel home", 25 + "Polymodel" 26 + } 27 + nav { class: "app-nav", aria_label: "Primary", 28 + Link { class: "app-nav-link", active_class: "is-active", to: Route::Browse {}, "Browse" } 29 + } 30 + ShellSearch {} 31 + Link { class: "button button-primary app-publish-cta", to: Route::Publish {}, "Publish" } 32 + SessionControl { session, toasts } 33 + } 34 + } 35 + div { class: "app-content", 36 + Outlet::<Route> {} 37 + } 38 + } 39 + } 40 + } 41 + 42 + /// Global directed-search field. Submits client-side through the navigator to 43 + /// the `/search` route; it never performs a full document navigation (contrast 44 + /// `SessionControl`, which issues a real GET to `/oauth/start`). 45 + #[component] 46 + fn ShellSearch() -> Element { 47 + let mut query = use_signal(String::new); 48 + 49 + rsx! { 50 + div { class: "app-search", role: "search", 51 + input { 52 + class: "app-search-input", 53 + r#type: "search", 54 + name: "q", 55 + placeholder: "Search the catalog", 56 + aria_label: "Search Polymodel", 57 + value: "{query}", 58 + oninput: move |event| query.set(event.value()), 59 + onkeydown: move |event| { 60 + if event.key() == Key::Enter { 61 + event.prevent_default(); 62 + let q = query.read().trim().to_string(); 63 + navigator().push(Route::Search { q }); 64 + } 65 + }, 66 + } 67 + button { 68 + class: "button button-secondary app-search-button", 69 + r#type: "button", 70 + onclick: move |_| { 71 + let q = query.read().trim().to_string(); 72 + navigator().push(Route::Search { q }); 73 + }, 74 + "Search" 75 + } 76 + } 77 + } 78 + } 79 + 80 + /// Returns the current route as a path string for use as an OAuth `return_to` 81 + /// value, so signing in returns the user to the page they started on. 82 + fn current_return_to() -> String { 83 + use_route::<Route>().to_string() 84 + } 85 + 7 86 #[allow(clippy::useless_format)] 8 87 #[component] 9 88 pub(crate) fn SessionControl( ··· 11 90 toasts: Signal<ToastState>, 12 91 ) -> Element { 13 92 let identity = session.read().clone(); 93 + let return_to = current_return_to(); 14 94 15 95 match identity { 16 96 SessionIdentity::Anonymous => rsx! { ··· 18 98 class: "session-control", 19 99 action: "/oauth/start", 20 100 method: "get", 21 - input { r#type: "hidden", name: "return_to", value: "/" }, 101 + input { r#type: "hidden", name: "return_to", value: "{return_to}" }, 22 102 input { 23 103 r#type: "text", 24 104 name: "identifier",
+1 -3
src/thing_detail.rs
··· 179 179 180 180 rsx! { 181 181 main { class: "thing-detail-page product-shell", 182 - nav { class: "detail-nav", aria_label: "Thing detail navigation", 183 - Link { to: Route::Browse {}, "← Back to browse" } 184 - } 185 182 match state { 186 183 DetailState::Loading => rsx! { DetailLoading {} }, 187 184 DetailState::InvalidRoute(message) => rsx! { DetailError { title: "Thing route unavailable".to_string(), message } }, ··· 197 194 fn DetailLoading() -> Element { 198 195 rsx! { 199 196 section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing detail loading", 197 + h1 { class: "sr-only", "Loading thing" } 200 198 div { class: "state-card loading-state", 201 199 span { class: "status-pill", "Loading" } 202 200 div { class: "skeleton-line skeleton-wide" }
-4
src/viewer_route.rs
··· 1 1 use dioxus::prelude::*; 2 2 3 - use crate::Route; 4 3 use crate::viewer::{ViewerPage, demo_model_for_route}; 5 4 6 5 #[component] ··· 9 8 10 9 rsx! { 11 10 main { class: "viewer-route-page", 12 - nav { class: "viewer-route-nav", aria_label: "Viewer navigation", 13 - Link { to: Route::Browse {}, "← Back to browse" } 14 - } 15 11 ViewerPage { key: "{model.id}", initial_model: model } 16 12 } 17 13 }