From 42472e3d8470c5716ce62c93d261b2248fa83526 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 28 Jul 2026 19:46:51 -0500 Subject: [PATCH 01/38] Rework the visual system: tokens, elevation, and card alignment Most of the inconsistency traced back to components hand-pairing their colors (`bg-white dark:bg-fade-900`, `border-gray-300 dark:border-gray-700`), so nothing agreed across surfaces. Adds semantic tokens (surface, line, ink, ink-muted, ink-subtle) in globals.scss and exposes them to Tailwind, so components name a role rather than a color pair. Notable fixes: - Elevation was inverted in dark mode: cards were darker than the panel they sat on. The page ground is now fade-950, raised surfaces fade-850, with one shadow language instead of a 1px border under a wide shadow. - Removed the floating tinted panel that nested the page inside itself. Content sits on the ground so cards read as raised. - Model cards used two different layouts (paired thumbnails overlaid their details, standalone ones stacked below), so titles landed at different heights across a row. One layout now, with a fixed plate and detail block. - Grouped the simple tag filter by its real categories. Selection behavior is unchanged. - Added a footer; every page previously ended abruptly. - The alpha badge was an `::after` pinned into the wordmark. Removed along with the alpha notice and footer line, per maintainer request. - Model page metadata moved from a right-aligned filled-header table to a spec list; its h1 had no size class at all. Accessibility: adds a global :focus-visible ring (previously only the search input had one) and a prefers-reduced-motion block, which the existing hover transforms had no handling for. Two traps worth knowing about, since this project disables Tailwind Preflight: `border-solid` with a single-side width paints all four sides at the UA `medium` width, and CSS-module declarations override Tailwind utilities. Both are commented where they applied. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/license-attributes.tsx | 1 - .../components/model-card.module.scss | 96 +++++++------- src/elements/components/model-card.tsx | 103 ++++++++------- .../components/model-results.module.scss | 5 + src/elements/components/model-results.tsx | 22 +-- src/elements/components/searchbar.tsx | 64 ++++++--- src/elements/footer.module.scss | 125 ++++++++++++++++++ src/elements/footer.tsx | 124 +++++++++++++++++ src/elements/header.module.scss | 91 +++++-------- src/elements/header.tsx | 121 +++++++++-------- src/elements/page.module.scss | 14 +- src/elements/page.tsx | 13 +- src/elements/tag-selector.module.scss | 77 ++++++++--- src/elements/tag-selector.tsx | 99 ++++++++------ src/lib/hooks/use-tooltip.tsx | 5 + src/pages/index.tsx | 72 +++++----- src/pages/models/[id].tsx | 82 +++++++----- src/styles/colors.scss | 2 + src/styles/globals.scss | 69 +++++++++- tailwind.config.js | 24 ++++ 20 files changed, 813 insertions(+), 396 deletions(-) create mode 100644 src/elements/footer.module.scss create mode 100644 src/elements/footer.tsx diff --git a/src/elements/components/license-attributes.tsx b/src/elements/components/license-attributes.tsx index b393cfdd..8b6cb9e4 100644 --- a/src/elements/components/license-attributes.tsx +++ b/src/elements/components/license-attributes.tsx @@ -213,7 +213,6 @@ function Item({ title, description, color }: { title: string; description?: stri {title} diff --git a/src/elements/components/model-card.module.scss b/src/elements/components/model-card.module.scss index ca9f4a6c..c9e137b3 100644 --- a/src/elements/components/model-card.module.scss +++ b/src/elements/components/model-card.module.scss @@ -5,31 +5,34 @@ .modelCard { position: relative; - height: 350px; - border-radius: 0.5rem; - border-width: 1px; - border-style: solid; - transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; + display: flex; + flex-direction: column; + height: 352px; + box-sizing: border-box; + border-radius: var(--radius-card); + border: 1px solid var(--line-color); + background-color: var(--surface); + + // One elevation language: a hairline border plus one soft shadow, both + // moving together on hover. Previously the border and the shadow disagreed. + box-shadow: var(--shadow-card); + transition: transform 0.18s ease-out, box-shadow 0.18s ease-out, border-color 0.18s ease-out; z-index: 1; - --inner-radius: calc(0.5rem - 1px); - - @include themed using($t) { - box-shadow: t($t, 0 1px 3px rgb(0 0 0 / 5%), 0 1px 3px rgb(0 0 0 / 15%)); - } + --inner-radius: calc(var(--radius-card) - 1px); &:hover { - transform: translateY(-4px); + transform: translateY(-3px); + border-color: var(--line-strong); + box-shadow: var(--shadow-raised); z-index: 10; + } - @include themed using($t) { - box-shadow: t($t, 0 12px 24px -8px rgb(0 0 0 / 15%), 0 12px 24px -8px rgb(0 0 0 / 40%)); - } + &:focus-within { + border-color: var(--accent-text); } &.overflowHidden { - --inner-radius: 0; - overflow: hidden; } @@ -45,6 +48,7 @@ flex-direction: column; height: 100%; width: 100%; + min-height: 0; } .topTags { @@ -53,14 +57,17 @@ right: 0; margin: 0.5rem; display: flex; - flex-flow: row wrap; - column-gap: 0.5rem; + flex-flow: row nowrap; + gap: 0.375rem; z-index: 1; pointer-events: none; } + // Every card gets the same plate height so names, authors and tags land on + // the same baseline across a row, whether the thumbnail is paired or not. .thumbnail { - height: 100%; + height: 176px; + flex: 0 0 176px; width: 100%; background-size: cover; background-position: center; @@ -70,46 +77,43 @@ position: relative; display: flex; align-items: center; - justify-items: center; - vertical-align: middle; - - &.paired { - border-radius: var(--inner-radius); - } + justify-content: center; } .details { - padding: 0.75rem; - padding-top: 0; + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 0.125rem; + padding: 0.75rem 0.875rem 0.875rem; overflow: visible; position: relative; z-index: 10; - - &.paired { - position: absolute; - left: 0; - bottom: 0; - right: 0; - backdrop-filter: blur(2rem) saturate(2); - border-radius: 0 0 var(--inner-radius) var(--inner-radius); - overflow: visible; - - @include themed using($t) { - background: linear-gradient(transparent, t($t, white, $fade-900)) - t($t, color.change(white, $alpha: 0.7), color.change($fade-900, $alpha: 0.8)); - } - } + border-top: 1px solid var(--line-color); a.name { word-break: normal; overflow-wrap: anywhere; - padding-top: 0.5rem; } } + .tagRow { + display: flex; + flex-flow: row wrap; + gap: 0.25rem; + margin-top: auto; + max-height: 1.75rem; + overflow: hidden; + } + .tagBase { - border-radius: 0.5rem; - padding: 0.25rem 0.5rem; - font-weight: 500; + border-radius: 0.375rem; + padding: 0.15rem 0.45rem; + font-weight: 600; + font-size: 0.75rem; + line-height: 1.4; + letter-spacing: 0.01em; + white-space: nowrap; } } diff --git a/src/elements/components/model-card.tsx b/src/elements/components/model-card.tsx index 9f8538db..7e180fef 100644 --- a/src/elements/components/model-card.tsx +++ b/src/elements/components/model-card.tsx @@ -60,7 +60,12 @@ const SideBySideImage = ({ model, image }: { model: Model; image: PairedThumbnai const scale = (1 / dpr) * Math.max(1, Math.round(dpr + 0.16)); return ( -
+
+ {/* Marks the seam so the card reads as a before/after pair. */} +
{model.name} { const image = model?.thumbnail ?? model?.images[0]; if (!model || !image) { - return
No Image
; + return ( +
No preview
+ ); } switch (image.type) { case 'paired': { @@ -118,7 +125,7 @@ const getModelCardImageComponent = (model: Model | undefined) => { return ( {model.name} @@ -138,37 +145,36 @@ const ModelCardContent = memo(({ id, model }: ModelCardProps) => { const { updateModelProperty } = useUpdateModel(webApi, id); const description = getTextDescription(model); - const isPaired = model.images[0]?.type === 'paired' && !editMode; return (
- {/* Arch tag on image */} + {/* Architecture and scale, over the image */}
- {archData.get(model.architecture)?.name ?? 'Unknown'} - {model.scale}x + {archData.get(model.architecture)?.name ?? 'Unknown'} + {model.scale}x
{getModelCardImageComponent(model)} -
+
{model.name} -
+
{'by '} {joinList( asArray(model.author).map((userId) => ( @@ -179,10 +185,10 @@ const ModelCardContent = memo(({ id, model }: ModelCardProps) => {
{/* Description */} -
{description}
+
{description}
{/* Tags */} -
+
{ const CollectionCardContent = memo(({ id, collection, preview }: CollectionCardProps) => { const { userData } = useUsers(); - const isPaired = preview?.images[0]?.type === 'paired'; - return (
- {/* Arch tag on image */}
- + + + Collection +
{getModelCardImageComponent(preview)} -
+
{collection.name} -
+
{'by '} {joinList( asArray(collection.author).map((userId) => ( @@ -241,7 +247,7 @@ const CollectionCardContent = memo(({ id, collection, preview }: CollectionCardP
{/* Description */} -
+
{collection.description}
@@ -252,31 +258,11 @@ const CollectionCardContent = memo(({ id, collection, preview }: CollectionCardP const useMakeLazyCard = (lazy: boolean, card: JSX.Element) => { const { editMode } = useWebApi(); - const inner = ( -
- {card} -
- ); + const inner =
{card}
; if (!lazy) return inner; - return ( - - } - > - {inner} - - ); + return }>{inner}; }; export const ModelCard = memo(({ id, model, lazy = false }: ModelCardProps) => { @@ -300,6 +286,21 @@ export const CollectionCard = memo(({ id, collection, preview, lazy = false }: C ); }); -function AccentTag({ children }: React.PropsWithChildren) { - return
{children}
; +/** + * A chip that sits directly on top of a thumbnail, so it needs to stay legible + * over arbitrary image content. `emphasis` marks the scale, which is the + * property people filter on first. + */ +function OverlayTag({ children, emphasis }: React.PropsWithChildren<{ emphasis?: boolean }>) { + return ( +
+ {children} +
+ ); } diff --git a/src/elements/components/model-results.module.scss b/src/elements/components/model-results.module.scss index 3b0d289b..ce54ff41 100644 --- a/src/elements/components/model-results.module.scss +++ b/src/elements/components/model-results.module.scss @@ -2,6 +2,11 @@ display: flex; align-items: center; + // Declared here rather than with Tailwind's `border-b border-solid`: this + // project disables Preflight, so `border-solid` without all four widths set + // falls back to the UA `medium` width and paints a full box. + border-bottom: 1px solid var(--line-color); + @media screen and (max-width: 380px) { flex-direction: column; align-items: flex-start; diff --git a/src/elements/components/model-results.tsx b/src/elements/components/model-results.tsx index 544aa4b3..af8b755a 100644 --- a/src/elements/components/model-results.tsx +++ b/src/elements/components/model-results.tsx @@ -22,11 +22,14 @@ export const ModelResults = memo(({ models, modelData, sort, setSort }: ModelRes return ( <> -
- - Found {sortedModels.length} model - {sortedModels.length === 1 ? '' : 's'} - +
+

+ {sortedModels.length.toLocaleString('en-US')}{' '} + {sortedModels.length === 1 ? 'model' : 'models'} +

{SORT_OPTIONS[sort].label} @@ -78,7 +82,7 @@ export function SortSelector({ sort, setSort }: { sort: Sort; setSort: (sort: So leaveTo="opacity-0" > {typedEntries(SORT_OPTIONS).map(([value, { label, hide }]) => { if (hide) return null; @@ -87,9 +91,7 @@ export function SortSelector({ sort, setSort }: { sort: Sort; setSort: (sort: So `relative cursor-pointer select-none py-2 px-4 ${ - active - ? 'bg-gray-200 text-black dark:bg-accent-600 dark:text-white' - : '' + active ? 'bg-accent-600 text-white dark:bg-accent-500' : '' }` } key={value} diff --git a/src/elements/components/searchbar.tsx b/src/elements/components/searchbar.tsx index c2637f1d..1e10d74b 100644 --- a/src/elements/components/searchbar.tsx +++ b/src/elements/components/searchbar.tsx @@ -9,6 +9,9 @@ type SearchBarProps = { onEnter?: (event: KeyboardEvent) => void; placeholder?: string; brightIcon?: boolean; + /** `large` is for a page's primary search; `default` for the header and inline use. */ + size?: 'default' | 'large'; + 'aria-label'?: string; }; export const SearchBar = ({ @@ -18,26 +21,45 @@ export const SearchBar = ({ onEnter, placeholder = 'Search', brightIcon, -}: SearchBarProps) => ( -
-
-
-); + ); +}; diff --git a/src/elements/footer.module.scss b/src/elements/footer.module.scss new file mode 100644 index 00000000..277b98ad --- /dev/null +++ b/src/elements/footer.module.scss @@ -0,0 +1,125 @@ +@import '../styles/theme'; + +.footer { + flex-shrink: 0; + margin-top: var(--stack-section); + border-top: 1px solid var(--line-color); + background-color: var(--surface-sunken); +} + +.inner { + max-width: var(--max-page-width); + box-sizing: border-box; + margin: 0 auto; + padding: var(--stack-loose) var(--page-gutter) var(--stack); +} + +.columns { + display: grid; + gap: var(--stack-loose) var(--stack); + grid-template-columns: 1fr; + + @media only screen and (min-width: 640px) { + grid-template-columns: repeat(3, 1fr); + } + + @media only screen and (min-width: 1024px) { + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: var(--stack-loose); + } +} + +.about { + @media only screen and (min-width: 640px) and (max-width: 1023px) { + grid-column: 1 / -1; + } +} + +.blurb { + margin: 0; + max-width: 42ch; + font-size: 0.9375rem; + line-height: 1.6; + color: var(--font-color-light); +} + +.social { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: var(--stack-tight); +} + +.socialLink { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + border: 1px solid var(--line-color); + border-radius: var(--radius-control); + background-color: var(--surface); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.2; + transition: border-color 0.15s ease, color 0.15s ease; + + &:hover { + color: var(--font-color-hover); + border-color: var(--line-strong); + } +} + +.columnTitle { + margin: 0 0 0.75rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--font-color-subtle); +} + +.list { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0; + margin: 0; + list-style: none; +} + +.link { + font-size: 0.9375rem; + color: var(--font-color-light); + + &:hover { + color: var(--font-color-hover); + text-decoration: underline; + text-underline-offset: 0.2em; + } +} + +.legal { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: var(--stack-loose); + padding-top: var(--stack); + border-top: 1px solid var(--line-color); + font-size: 0.8125rem; + color: var(--font-color-subtle); +} + +.legalSpacer { + flex: 1 0 auto; +} + +.legalLink { + color: var(--font-color-light); + + &:hover { + color: var(--font-color-hover); + text-decoration: underline; + text-underline-offset: 0.2em; + } +} diff --git a/src/elements/footer.tsx b/src/elements/footer.tsx new file mode 100644 index 00000000..a0c72ca0 --- /dev/null +++ b/src/elements/footer.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { FaDiscord, FaGithub } from 'react-icons/fa'; +import { Link } from './components/link'; +import style from './footer.module.scss'; + +interface FooterLink { + label: string; + href: string; + external?: boolean; +} + +const BROWSE: FooterLink[] = [ + { label: 'All models', href: '/' }, + { label: 'Architectures', href: '/architectures' }, + { label: 'Tags', href: '/tags' }, + { label: 'Authors', href: '/users' }, +]; + +const LEARN: FooterLink[] = [ + { label: 'How to upscale', href: '/docs/faq' }, + { label: 'Training', href: '/docs/training' }, + { label: 'Licenses', href: '/docs/licenses' }, +]; + +const CONTRIBUTE: FooterLink[] = [ + { label: 'Contributing guide', href: '/docs/contributing' }, + { label: 'Add a model', href: '/docs/contributing/models' }, + { label: 'Report a bug', href: 'https://github.com/OpenModelDB/open-model-database/issues', external: true }, + { + label: 'Share feedback', + href: 'https://github.com/OpenModelDB/open-model-database/discussions/new?category=general', + external: true, + }, +]; + +function FooterColumn({ title, links }: { title: string; links: FooterLink[] }) { + return ( +
+

{title}

+
    + {links.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ ); +} + +export function Footer() { + return ( +
+
+
+
+

+ A community-driven database of AI upscaling models. Everything here is maintained in the + open — corrections and new models are welcome. +

+
+ + + GitHub + + + + Discord + +
+
+ + + + +
+ +
+ Built and maintained by the upscaling community. + + + Site source under GPL-3.0 + + · + + Models carry their own licenses + +
+
+
+ ); +} diff --git a/src/elements/header.module.scss b/src/elements/header.module.scss index 0023f784..0d62ff40 100644 --- a/src/elements/header.module.scss +++ b/src/elements/header.module.scss @@ -12,8 +12,7 @@ .header { @include themed using ($t) { - background: linear-gradient(t($t, white, $fade-900), transparent) - t($t, color.change(white, $alpha: 0.85), color.change($fade-900, $alpha: 0.9)); + background-color: t($t, color.change(white, $alpha: 0.82), color.change(#111117, $alpha: 0.82)); } transition: background-color 0.25s ease-out, border-color 0.25s ease-out; @@ -29,82 +28,64 @@ & > div { @include h-stack; + gap: 0.25rem; + height: 100%; max-width: var(--max-page-width); box-sizing: border-box; - padding: 0.5em 1em; + padding: 0 var(--page-gutter); margin: auto; } .logo { flex-shrink: 0; - width: 250px; - position: relative; - - &::after { - content: 'alpha'; - position: absolute; - right: 0; - bottom: -0.5rem; - font-size: 1rem; - - // transform: rotate(15deg); - - @include themed using ($t) { - color: t($t, $accent-600, $accent-500); - } - } + display: flex; + align-items: center; + border-radius: var(--radius-control); .logoContainer { display: flex; - height: 48px; - width: 250px; + height: 44px; + width: 210px; align-items: center; - align-content: center; - justify-content: center; - justify-items: center; + justify-content: flex-start; } @media only screen and (max-width: 400px) { - width: 44px; - overflow: hidden; - - &::after { - display: none; + .logoContainer { + width: 44px; + overflow: hidden; } } } .search { - // The following looks complicated, but is quite simple. The goal is - // first determine the width of the download button, and then subtract - // the header buttons on the right: - // 100% the inner width of the header - // - 2rem remove the 2 1rem gaps in the grid layout of the model page - // / 3 the page is divided into a grid with 3 columns. - // At this point, we have the width of the download button exactly. - // - 2em * 1.5 * 3 the 3 buttons to the right of the search bar - // - 1rem the right padding of the search bar - width: calc((100% - 2rem) / 3 - 2em * 1.5 * 3 - 1rem); + width: clamp(12rem, 22vw, 20rem); box-sizing: border-box; } } +// Deliberately not nested inside `.header`: `.header .nav` would outrank the +// single-class `.hideMobile` and the nav would render alongside the hamburger. +.nav { + @include h-stack; + + margin-left: 0.75rem; + gap: 0.25rem; +} + .docLink { cursor: pointer; font-family: inherit; - font-size: 100%; - line-height: inherit; + font-size: 0.9375rem; + font-weight: 500; + line-height: 1.2; border: none; - border-radius: 0.5rem; - padding: 0.5rem 1rem; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, transform; + border-radius: var(--radius-control); + padding: 0.5rem 0.75rem; + white-space: nowrap; + transition-property: color, background-color; transition-timing-function: ease-out; transition-duration: 150ms; - white-space: nowrap; - - &:active { - transform: scale(0.97); - } } .iconLink, @@ -112,21 +93,17 @@ background: none; border: none; padding: 0; - margin: 0.5em; - font-size: 150%; + margin: 0 0.375em; + font-size: 137.5%; cursor: pointer; - transition: color 0.15s ease, transform 0.15s ease; + transition: color 0.15s ease; width: 1em; height: 1em; line-height: 1; + color: var(--font-color-light); &:hover { color: var(--font-color-hover); - transform: scale(1.1); - } - - &:active { - transform: scale(0.95); } } diff --git a/src/elements/header.tsx b/src/elements/header.tsx index 47c98147..95360d9d 100644 --- a/src/elements/header.tsx +++ b/src/elements/header.tsx @@ -49,28 +49,22 @@ export function Header({ searchBar }: HeaderProps) {
- - How To Upscale - - {editMode && ( + @@ -78,7 +72,7 @@ export function Header({ searchBar }: HeaderProps) { -
- Help - -
+
+
+ Help + How To Upscale -
- - Links - -
+ +
+ +
+ Links + GitHub -
- - -
+ + Discord -
- - Settings -
- + +
)} diff --git a/src/elements/page.module.scss b/src/elements/page.module.scss index ef79db96..e5ab0980 100644 --- a/src/elements/page.module.scss +++ b/src/elements/page.module.scss @@ -1,13 +1,21 @@ .container { background-color: transparent; + display: flex; + min-height: 100vh; + flex-direction: column; } .main { max-width: var(--max-page-width); box-sizing: border-box; - margin: auto; + width: 100%; + margin: 0 auto; + padding: 0 var(--page-gutter); + flex: 1 0 auto; } -.wrapper { - transition: background-color linear 0.2s; +// Content column that owns its own vertical rhythm at the top and bottom. +.padded { + padding-top: var(--stack); + padding-bottom: var(--stack-loose); } diff --git a/src/elements/page.tsx b/src/elements/page.tsx index 5d86163d..06c67fbe 100644 --- a/src/elements/page.tsx +++ b/src/elements/page.tsx @@ -1,5 +1,7 @@ import { ReactNode } from 'react'; +import { joinClasses } from '../lib/util'; import { ScrollToTop } from './components/scroll-to-top'; +import { Footer } from './footer'; import { Header } from './header'; import style from './page.module.scss'; @@ -13,15 +15,8 @@ export function PageContainer({ children, scrollToTop, wrapper, searchBar }: Pag return (
- {wrapper ? ( -
-
- {children} -
-
- ) : ( -
{children}
- )} +
{children}
+
{scrollToTop && }
); diff --git a/src/elements/tag-selector.module.scss b/src/elements/tag-selector.module.scss index b578388f..8f6cb7e8 100644 --- a/src/elements/tag-selector.module.scss +++ b/src/elements/tag-selector.module.scss @@ -2,14 +2,20 @@ .tagSelector { color: inherit; - - h4 { - margin: 0.5rem 0; - } + display: flex; + flex-direction: column; + gap: 0.875rem; &.advanced { + gap: 0.75rem; + + h4 { + margin: 0.5rem 0; + } + @media only screen and (min-width: 640px) { display: grid; + gap: 0; grid-template-columns: 1fr 1000fr; h4 { @@ -24,25 +30,58 @@ } } +// Grouped simple selector: a quiet category label above each row of options. +.group { + display: grid; + gap: 0.375rem 1rem; + align-items: baseline; + grid-template-columns: 1fr; + + @media only screen and (min-width: 768px) { + grid-template-columns: 5.5rem 1fr; + gap: 0.5rem 1rem; + } +} + +.groupTitle { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + line-height: 1.9; + text-transform: uppercase; + color: var(--font-color-subtle); + + @media only screen and (min-width: 768px) { + text-align: right; + } +} + +.groupTags { + display: flex; + flex-flow: row wrap; + gap: 0.375rem; +} + .tagButton { - margin: 0 0.5em 0.5em 0; font: inherit; cursor: pointer; + + // No `background-color` here: this file is injected after Tailwind's + // utilities, so any value set here beats the `bg-*` class each state + // supplies. Every state sets its own background instead. font-size: 0.875rem; - border-radius: 0.5rem; + border-radius: var(--radius-control); padding: 0; - border: none; + + // Width and style only. The `border` shorthand would reset border-color to + // currentColor and beat the `border-*` utility each state supplies. + border-width: 1px; + border-style: solid; + box-sizing: border-box; display: inline-flex; overflow: hidden; - transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease; - - &:hover { - transform: translateY(-1px); - } - - &:active { - transform: translateY(0); - } + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; .icon { --extra-size: 0.2em; @@ -67,7 +106,7 @@ line-height: 1.4; height: 1.4em; font-weight: 500; - padding: 0.333rem 0.625rem; + padding: 0.3rem 0.6rem; display: inline-block; } } @@ -75,7 +114,8 @@ .controls { display: flex; flex-wrap: wrap; - margin-top: 0.25rem; + gap: 1rem; + margin-top: 0.875rem; } .modeButton { @@ -86,7 +126,6 @@ padding: 0.25rem 0; display: inline-flex; align-items: center; - margin-right: 1rem; &:disabled { cursor: default; diff --git a/src/elements/tag-selector.tsx b/src/elements/tag-selector.tsx index 6c2621a8..c342a4da 100644 --- a/src/elements/tag-selector.tsx +++ b/src/elements/tag-selector.tsx @@ -32,16 +32,16 @@ function TagButton({ state, name, onClick, noIcon = false, tooltipContent }: Tag return ( )} -
-

Notice

-

- OpenModelDB is still in alpha and actively being worked on. Please feel free to{' '} - - share your feedback - {' '} - and{' '} - - report any bugs - {' '} - you find. -

-
+
+

+ The best place to find AI Upscaling models +

-

- The best place to find - - AI Upscaling - - models -

- -

- OpenModelDB is a community driven database of AI Upscaling models. We aim to provide a better way to - find and compare models than existing sources. -

+

+ OpenModelDB is a community driven database of AI Upscaling models. We aim to provide a better + way to find and compare models than existing sources. +

+
{/* Search */} setSearchQuery(e.target.value, 400)} onEnter={(e) => { @@ -173,7 +151,7 @@ export default function Page({ modelData: staticModelData }: Props) { /> {/* Tags */} -
+
{ @@ -193,9 +171,23 @@ export default function Page({ modelData: staticModelData }: Props) { sort={sort} /> ) : ( -
-
No models found
-
Try changing your search filters
+
+

No models match those filters

+

+ {searchQuery + ? `Nothing matched “${searchQuery}”. Try a broader term, or clear the filters to start over.` + : 'That combination of tags has no models. Try removing one, or clear the filters to start over.'} +

+
)} diff --git a/src/pages/models/[id].tsx b/src/pages/models/[id].tsx index 3155e916..7913bfd9 100644 --- a/src/pages/models/[id].tsx +++ b/src/pages/models/[id].tsx @@ -33,7 +33,15 @@ import { getSimilarModels } from '../../lib/similar'; import { IS_DEPLOYED } from '../../lib/site-data'; import { STATIC_ARCH_DATA } from '../../lib/static-data'; import { getTextDescription } from '../../lib/text-description'; -import { EMPTY_ARRAY, asArray, getColorMode, getPreviewImage, joinListString, typedKeys } from '../../lib/util'; +import { + EMPTY_ARRAY, + asArray, + getColorMode, + getPreviewImage, + joinClasses, + joinListString, + typedKeys, +} from '../../lib/util'; import { validateModel } from '../../lib/validate-model'; const MAX_SIMILAR_MODELS = 12 * 2; @@ -333,34 +341,35 @@ function isTrue(value: T | null | undefined | false | '' | 0): value is T { return Boolean(value); } +/** + * Model properties, as a specification list. Labels are quiet micro-type in a + * fixed left column so the values read as the content; previously the labels + * sat in a filled, right-aligned header column that outweighed them. + */ function MetadataTable({ rows }: { rows: (false | null | undefined | readonly [string, ReactNode])[] }) { const filteredRows = rows.filter(isTrue); return ( -
- - - {filteredRows.map((row, i) => { - const [label, value] = row; - const extraPadding = i === 0 ? 'pt-3' : i === filteredRows.length - 1 ? 'pb-3' : ''; - const isLastRow = i === filteredRows.length - 1; - return ( - - - - - ); - })} - -
- {label} - {value}
-
+
+ {filteredRows.map((row, i) => { + const [label, value] = row; + return ( +
0 && 'border-x-0 border-t border-b-0 border-solid border-line' + )} + key={i} + > +
+ {label} +
+
{value}
+
+ ); + })} +
); } export default function Page({ @@ -541,14 +550,21 @@ export default function Page({ )}
)} -

+

updateModelProperty('name', value)} />

-
+
+ + {model.scale}x + + + {archName} + + · )} {collections.length > 0 && ( -
-

Collections that include this model

+
+

+ Collections that include this model +

)} {similar.length > 0 && ( -
-

Similar Models

+
+

Similar models

{editMode && similarWithScores.length > 0 && (
Show scores{' '} diff --git a/src/styles/colors.scss b/src/styles/colors.scss index 03fa6a79..54a82cb9 100644 --- a/src/styles/colors.scss +++ b/src/styles/colors.scss @@ -33,7 +33,9 @@ $fade-500: #72717a; $fade-600: #55545c; $fade-700: #403f47; $fade-800: #28272f; +$fade-850: #1f1e26; // raised surfaces in dark mode, between 800 and 900 $fade-900: #17171e; +$fade-950: #111117; // sunken page ground in dark mode // easy access to tailwind's neutral palette $n-50: #fafafa; diff --git a/src/styles/globals.scss b/src/styles/globals.scss index 73026dd5..4213b134 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -10,17 +10,31 @@ @mixin theme-colors($t) { --accent: #{t($t, $accent-600, $accent-500)}; --accent-2: #2d2d59; + --accent-text: #{t($t, $accent-600, $accent-400)}; - --page-bg: #{t($t, white, $fade-900)}; + --page-bg: #{t($t, $fade-50, $fade-950)}; - --font-color: #{t($t, black, $fade-100)}; + // Elevation. Raised surfaces move *toward* the light source in both themes: + // lighter than the page in dark mode, whiter than the tinted ground in light mode. + --surface: #{t($t, white, $fade-850)}; + --surface-sunken: #{t($t, $fade-100, $fade-900)}; + --surface-hover: #{t($t, $fade-100, $fade-800)}; + + --font-color: #{t($t, $fade-900, $fade-100)}; --font-color-hover: #{t($t, $accent-700, $accent-400)}; - --font-color-light: #{t($t, $fade-700, $fade-400)}; + --font-color-light: #{t($t, $fade-600, $fade-400)}; --font-color-light-hover: #{t($t, black, white)}; + --font-color-subtle: #{t($t, $fade-500, $fade-500)}; + + --line-color: #{t($t, $fade-200, #2c2b35)}; + --line-strong: #{t($t, $fade-300, $fade-700)}; - --line-color: #{t($t, $fade-300, $fade-700)}; + --link-color: #{t($t, hsl(212deg 100% 38%), hsl(212deg 100% 72%))}; - --link-color: #{t($t, hsl(212deg 100% 45%), hsl(212deg 100% 67%))}; + // One elevation language, used by every raised surface. + --shadow-card: #{t($t, 0 1px 2px rgb(23 23 30 / 6%), 0 1px 2px rgb(0 0 0 / 30%))}; + --shadow-raised: #{t($t, 0 6px 16px -6px rgb(23 23 30 / 14%), 0 6px 16px -6px rgb(0 0 0 / 50%))}; + --shadow-pop: #{t($t, 0 12px 32px -8px rgb(23 23 30 / 18%), 0 12px 32px -8px rgb(0 0 0 / 60%))}; --logo-text: #{t($t, $fade-900, white)}; --logo-jelly: #{t($t, white, white)}; @@ -49,6 +63,16 @@ :root { --max-page-width: 1400px; + --page-gutter: 1rem; + + // One vertical rhythm, used by every page section. + --stack-tight: 0.75rem; + --stack: 1.5rem; + --stack-loose: 2.5rem; + --stack-section: 4rem; + + --radius-control: 0.5rem; + --radius-card: 0.75rem; --fonts-heading: var(--font-inter), -apple-system, blinkmacsystemfont, 'Segoe UI', helvetica, arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; @@ -57,6 +81,10 @@ --fonts-mono: sfmono-regular, menlo, monaco, consolas, 'Liberation Mono', 'Courier New', monospace; overflow-y: scroll; + + @media only screen and (min-width: 768px) { + --page-gutter: 1.5rem; + } } html, @@ -98,6 +126,7 @@ h4, h5, h6 { font-family: var(--fonts-heading); + text-wrap: balance; } a { @@ -111,6 +140,36 @@ a { color: white; } +// Keyboard focus was previously invisible on every control except the search +// input. One ring, on every focusable thing, in both themes. +:focus-visible { + outline: 2px solid var(--accent-text); + outline-offset: 2px; + border-radius: 2px; +} + +// Controls that paint their own background get the ring on the outside. +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + outline-offset: 2px; + border-radius: var(--radius-control); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + .rendering-pixelated { image-rendering: pixelated; } diff --git a/tailwind.config.js b/tailwind.config.js index 50a18ff1..93696c19 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -24,7 +24,22 @@ module.exports = { 'fade-600': '#55545c', 'fade-700': '#403f47', 'fade-800': '#28272f', + 'fade-850': '#1f1e26', 'fade-900': '#17171e', + 'fade-950': '#111117', + + // Semantic tokens. Prefer these over hand-pairing `x dark:y`, + // which is where most of the theme drift came from. + page: 'var(--page-bg)', + surface: 'var(--surface)', + 'surface-sunken': 'var(--surface-sunken)', + 'surface-hover': 'var(--surface-hover)', + line: 'var(--line-color)', + 'line-strong': 'var(--line-strong)', + ink: 'var(--font-color)', + 'ink-muted': 'var(--font-color-light)', + 'ink-subtle': 'var(--font-color-subtle)', + 'accent-text': 'var(--accent-text)', accent: '#4d48a9', 'accent-50': '#feeaff', 'accent-100': '#f8e4ff', @@ -37,6 +52,15 @@ module.exports = { 'accent-800': '#031c74', 'accent-900': '#000c60', }, + borderRadius: { + control: 'var(--radius-control)', + card: 'var(--radius-card)', + }, + boxShadow: { + card: 'var(--shadow-card)', + raised: 'var(--shadow-raised)', + pop: 'var(--shadow-pop)', + }, }, screens: { sm: '640px', From 3b4a8bbb470bd5608f2aa6da018cbd0eab7e5c4a Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 28 Jul 2026 19:59:20 -0500 Subject: [PATCH 02/38] Make the model page's comparison viewer legible and group its metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comparison viewer is the site's core proof and was the least explained part of the page. Synchronized zoom and pan were already wired up through react-zoom-pan-pinch, but nothing indicated they existed, and neither half was labelled — a first-time visitor saw a rectangle with a handle in it. Adds shared viewer chrome used by both the comparison and standalone viewers: - Before/After corner labels on comparisons. - A zoom readout with zoom in/out and Fit controls, so the viewer is usable without a scroll wheel. - A persistent "Scroll to zoom / Drag to pan" hint, which adapts to touch and is hidden on small screens. Everything overlays arbitrary image content, so each piece carries its own scrim rather than relying on the surface beneath it. Other fixes on this page: - The carousel's prev/next arrows sat at the far edges of the page, a long way from the thumbnails they page. They are now one centred group with an image counter, and the group wraps rather than overflowing narrow screens. - Metadata was one flat list, giving nine training fields the same weight as the license, which is the field that decides whether a model can be used at all. Split into Model, Rights, and Training. - Tags sat unlabelled above the description and read as decoration; they now sit under a "Good for" heading. - Added a back link to the model list, and a "Using this model" block that points at the existing how-to. The page previously ended after the download with no indication of what runs the file. The standalone viewer needed an explicit content size once wrapped for the chrome: its image is sized in percentages, so the content box otherwise resolved to zero height. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/carousel/comparison.tsx | 205 ++++++++++-------- .../components/carousel/standalone.tsx | 67 ++++-- .../carousel/viewer-chrome.module.scss | 115 ++++++++++ .../components/carousel/viewer-chrome.tsx | 77 +++++++ src/elements/components/image-carousel.tsx | 25 ++- src/pages/models/[id].tsx | 147 ++++++++++--- 6 files changed, 484 insertions(+), 152 deletions(-) create mode 100644 src/elements/components/carousel/viewer-chrome.module.scss create mode 100644 src/elements/components/carousel/viewer-chrome.tsx diff --git a/src/elements/components/carousel/comparison.tsx b/src/elements/components/carousel/comparison.tsx index 91318210..a0abc1a9 100644 --- a/src/elements/components/carousel/comparison.tsx +++ b/src/elements/components/carousel/comparison.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { ReactCompareSlider, ReactCompareSliderHandle, ReactCompareSliderImage } from 'react-compare-slider'; import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; import { PairedImage } from '../../../lib/schema'; +import { CompareLabels, ViewerHint, ZoomControls } from './viewer-chrome'; type ImageComparisonProps = { image: PairedImage; @@ -99,103 +100,123 @@ export const ImageComparison = ({ image }: ImageComparisonProps) => { }; }, [image]); + // Drive both halves from the LR wrapper: the sync effect above mirrors any + // transform it reports onto the SR wrapper. + const zoomIn = useCallback(() => lrRef.current?.zoomIn(0.2, 0), []); + const zoomOut = useCallback(() => lrRef.current?.zoomOut(0.2, 0), []); + const reset = useCallback(() => { + lrRef.current?.centerView(1, 0); + setHandlePosition(50); + }, []); + return ( - - } - itemOne={ - { - setTransformState(state); - }} - > - + + } + itemOne={ + { + setTransformState(state); }} > - - - - } - itemTwo={ - { - setTransformState(state); - }} - > - + + + + } + itemTwo={ + { + setTransformState(state); }} > - - - - } - key={`${image.LR}-${image.SR}`} - position={handlePosition} - onPositionChange={setHandlePosition} - /> + + + + + } + key={`${image.LR}-${image.SR}`} + position={handlePosition} + onPositionChange={setHandlePosition} + /> + + + + +
); }; diff --git a/src/elements/components/carousel/standalone.tsx b/src/elements/components/carousel/standalone.tsx index 4caeaf19..6368abcc 100644 --- a/src/elements/components/carousel/standalone.tsx +++ b/src/elements/components/carousel/standalone.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; import { StandaloneImage } from '../../../lib/schema'; +import { ViewerHint, ZoomControls } from './viewer-chrome'; type ImageStandaloneProps = { image: StandaloneImage; @@ -8,33 +9,57 @@ type ImageStandaloneProps = { export const ImageStandalone = ({ image }: ImageStandaloneProps) => { const imgRef = useRef(null); + const [scale, setScale] = useState(1); useEffect(() => { imgRef.current?.centerView(1, 0); + setScale(1); }, [image]); + const zoomIn = useCallback(() => imgRef.current?.zoomIn(0.2, 0), []); + const zoomOut = useCallback(() => imgRef.current?.zoomOut(0.2, 0), []); + const reset = useCallback(() => imgRef.current?.centerView(1, 0), []); + return ( - - + setScale(state.scale)} > - {/* eslint-disable-next-line @next/next/no-img-element */} - image - - + + {/* eslint-disable-next-line @next/next/no-img-element */} + {image.caption + + + + + +
); }; diff --git a/src/elements/components/carousel/viewer-chrome.module.scss b/src/elements/components/carousel/viewer-chrome.module.scss new file mode 100644 index 00000000..f9dd1522 --- /dev/null +++ b/src/elements/components/carousel/viewer-chrome.module.scss @@ -0,0 +1,115 @@ +// Chrome that sits on top of the image viewers. Everything here overlays +// arbitrary image content, so each piece carries its own dark scrim rather +// than relying on the surface underneath it. + +.label { + position: absolute; + top: 0.625rem; + z-index: 20; + padding: 0.2rem 0.5rem; + border-radius: 0.375rem; + background-color: rgb(17 17 23 / 65%); + backdrop-filter: blur(4px); + color: white; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.4; + text-transform: uppercase; + pointer-events: none; + user-select: none; +} + +.labelBefore { + left: 0.625rem; +} + +.labelAfter { + right: 0.625rem; +} + +.hint { + position: absolute; + bottom: 0.75rem; + left: 0.625rem; + z-index: 20; + padding: 0.2rem 0.5rem; + border-radius: 0.375rem; + background-color: rgb(17 17 23 / 55%); + backdrop-filter: blur(4px); + color: rgb(255 255 255 / 85%); + font-size: 0.75rem; + line-height: 1.4; + pointer-events: none; + user-select: none; + + @media only screen and (max-width: 640px) { + display: none; + } +} + +.controls { + position: absolute; + bottom: 0.75rem; + right: 0.625rem; + z-index: 20; + display: flex; + align-items: center; + gap: 0.125rem; + padding: 0.1875rem; + border-radius: 0.5rem; + background-color: rgb(17 17 23 / 65%); + backdrop-filter: blur(4px); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + border-radius: 0.375rem; + background-color: transparent; + color: white; + font: inherit; + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: rgb(255 255 255 / 18%); + } + + &:focus-visible { + outline: 2px solid white; + outline-offset: -2px; + } +} + +.resetButton { + width: auto; + padding: 0 0.5rem; + font-size: 0.75rem; + font-weight: 600; +} + +.readout { + min-width: 3.25rem; + padding: 0 0.25rem; + color: white; + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: center; + user-select: none; +} + +.divider { + width: 1px; + height: 1.125rem; + margin: 0 0.125rem; + background-color: rgb(255 255 255 / 25%); +} diff --git a/src/elements/components/carousel/viewer-chrome.tsx b/src/elements/components/carousel/viewer-chrome.tsx new file mode 100644 index 00000000..06f45b0d --- /dev/null +++ b/src/elements/components/carousel/viewer-chrome.tsx @@ -0,0 +1,77 @@ +import { MdAdd, MdRemove } from 'react-icons/md'; +import { useIsTouch } from '../../../lib/hooks/use-is-touch'; +import { joinClasses } from '../../../lib/util'; +import style from './viewer-chrome.module.scss'; + +/** + * Marks which half of a comparison is the model's input and which is its + * output. Without these the slider is just a rectangle with a handle in it. + */ +export function CompareLabels() { + return ( + <> + Before + After + + ); +} + +/** + * Zoom and pan have always worked here, but nothing said so. This states the + * gesture and gives it visible controls, which also makes the viewer usable + * without a scroll wheel. + */ +export function ViewerHint() { + const isTouch = useIsTouch(); + + return {isTouch ? 'Pinch to zoom' : 'Scroll to zoom'} · Drag to pan; +} + +interface ZoomControlsProps { + /** Zoom factor relative to the default fitted view. */ + scale: number; + onZoomIn: () => void; + onZoomOut: () => void; + onReset: () => void; +} + +export function ZoomControls({ scale, onZoomIn, onZoomOut, onReset }: ZoomControlsProps) { + return ( +
+ + + {Math.round(scale * 100)}% + + + + +
+ ); +} diff --git a/src/elements/components/image-carousel.tsx b/src/elements/components/image-carousel.tsx index bee8a0c6..ff904202 100644 --- a/src/elements/components/image-carousel.tsx +++ b/src/elements/components/image-carousel.tsx @@ -25,6 +25,9 @@ type ImageCarouselProps = { const IMG_THUMB_SIZE = 96; +const CAROUSEL_ARROW = + 'inline-flex cursor-pointer items-center rounded-control border border-solid border-line bg-surface p-2 text-center text-sm text-ink transition-colors duration-100 ease-in-out hover:border-line-strong hover:bg-surface-hover'; + export const ImageCarousel = ({ images, readonly, indexKey, onChange }: ImageCarouselProps) => { const [imageIndex, setImageIndex] = useState(0); useEffect(() => { @@ -47,22 +50,26 @@ export const ImageCarousel = ({ images, readonly, indexKey, onChange }: ImageCar return (
{selectedImage ? ( ) : (
-
This model does not have preview images.
+
This model does not have preview images.
)}
{(!readonly || images.length > 1) && ( -
+ // Centred as one group: the arrows used to be pinned to the far + // edges of the page, a long way from the thumbnails they page. + // Wraps so the group never outgrows a narrow viewport. +
{images.length >= 2 && ( )} -
+
{sliceStartIndex > 0 && (
@@ -187,7 +194,8 @@ export const ImageCarousel = ({ images, readonly, indexKey, onChange }: ImageCar {images.length >= 2 && ( )} + {images.length >= 2 && ( + + {imageIndex + 1} / {images.length} + + )}
)}
diff --git a/src/pages/models/[id].tsx b/src/pages/models/[id].tsx index 7913bfd9..e3afaf0c 100644 --- a/src/pages/models/[id].tsx +++ b/src/pages/models/[id].tsx @@ -346,30 +346,71 @@ function isTrue(value: T | null | undefined | false | '' | 0): value is T { * fixed left column so the values read as the content; previously the labels * sat in a filled, right-aligned header column that outweighed them. */ -function MetadataTable({ rows }: { rows: (false | null | undefined | readonly [string, ReactNode])[] }) { +/** + * Closes the loop after the download button: the visitor now has a file and no + * idea what runs it. Links to the existing how-to rather than restating it. + */ +function UsingThisModel({ archName, scale }: { archName: string; scale: number }) { + return ( +
+

+ Using this model +

+

+ This is a {scale}x {archName} model. You run it in an upscaling application such as chaiNNer — the model + file itself is not an executable. +

+
+ + How to upscale → + + + Understanding licenses → + +
+
+ ); +} + +type MetadataRow = false | null | undefined | readonly [string, ReactNode]; + +function MetadataTable({ title, rows }: { title?: string; rows: MetadataRow[] }) { const filteredRows = rows.filter(isTrue); + if (filteredRows.length === 0) return null; + return ( -
- {filteredRows.map((row, i) => { - const [label, value] = row; - return ( -
0 && 'border-x-0 border-t border-b-0 border-solid border-line' - )} - key={i} - > -
- {label} -
-
{value}
-
- ); - })} -
+
+ {title && ( +

{title}

+ )} +
+ {filteredRows.map((row, i) => { + const [label, value] = row; + return ( +
0 && 'border-x-0 border-t border-b-0 border-solid border-line' + )} + key={i} + > +
+ {label} +
+
{value}
+
+ ); + })} +
+
); } export default function Page({ @@ -453,6 +494,15 @@ export default function Page({ )} +
+ + All models + +
+ {/* Full-width preview at top (YouTube-style) */}
-
- {editMode &&
tags:
} - updateModelProperty('tags', tags)} - /> +
+

+ Good for +

+
+ updateModelProperty('tags', tags)} + /> +
-
+
updateModelProperty('description', value)} />
+ +
{/* Right column: Sidebar */} -
+
{/* Download Button */} -
+
+ {model.resources.length > 1 && ( +

+ Downloads +

+ )} {model.resources.map((resource, index) => { return (
-
+
, ], + /* eslint-enable react/jsx-key */ + ]} + title="Model" + /> + + , ], + /* eslint-enable react/jsx-key */ + ]} + title="Rights" + /> + + { return [ @@ -734,6 +814,7 @@ export default function Page({ }), /* eslint-enable react/jsx-key */ ]} + title="Training" />
From edbd705dd001c97a07ca20e4061c4aa9a3b70273 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 28 Jul 2026 20:18:36 -0500 Subject: [PATCH 03/38] Move the viewer's zoom controls out of the caption's corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controls were anchored bottom-right, which is exactly where image captions are pinned — 290 of the 1625 example images carry one — and where example images frequently have their own text baked into the pixels. Controls and the gesture hint are now one group anchored bottom-left, so the four corners are: Before / After on top, controls bottom-left, caption bottom-right. The caption is capped at 55% width and ellipsised so a long one cannot run left into the controls, and the hint drops out below 900px where the two would otherwise meet in the middle. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/carousel/comparison.tsx | 3 +- .../components/carousel/standalone.tsx | 3 +- .../carousel/viewer-chrome.module.scss | 19 ++-- .../components/carousel/viewer-chrome.tsx | 96 ++++++++++--------- .../image-carousel-image.module.scss | 7 ++ 5 files changed, 72 insertions(+), 56 deletions(-) diff --git a/src/elements/components/carousel/comparison.tsx b/src/elements/components/carousel/comparison.tsx index a0abc1a9..7d6b1e47 100644 --- a/src/elements/components/carousel/comparison.tsx +++ b/src/elements/components/carousel/comparison.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { ReactCompareSlider, ReactCompareSliderHandle, ReactCompareSliderImage } from 'react-compare-slider'; import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; import { PairedImage } from '../../../lib/schema'; -import { CompareLabels, ViewerHint, ZoomControls } from './viewer-chrome'; +import { CompareLabels, ZoomControls } from './viewer-chrome'; type ImageComparisonProps = { image: PairedImage; @@ -210,7 +210,6 @@ export const ImageComparison = ({ image }: ImageComparisonProps) => { /> - { - {isTouch ? 'Pinch to zoom' : 'Scroll to zoom'} · Drag to pan; -} - interface ZoomControlsProps { /** Zoom factor relative to the default fitted view. */ scale: number; @@ -35,43 +24,58 @@ interface ZoomControlsProps { onReset: () => void; } +/** + * Zoom and pan have always worked here, but nothing said so. This states the + * gesture and gives it visible controls, which also makes the viewer usable + * without a scroll wheel. + * + * Anchored bottom-left as one group. Image captions are pinned to the + * bottom-right corner, and plenty of example images have their own text baked + * into that corner too, so the controls must stay out of it. + */ export function ZoomControls({ scale, onZoomIn, onZoomOut, onReset }: ZoomControlsProps) { + const isTouch = useIsTouch(); + return ( -
- - - {Math.round(scale * 100)}% - - - - +
+
+ + + {Math.round(scale * 100)}% + + + + +
+ + {isTouch ? 'Pinch to zoom' : 'Scroll to zoom'} · Drag to pan
); } diff --git a/src/elements/components/image-carousel-image.module.scss b/src/elements/components/image-carousel-image.module.scss index 2cfa6328..7d4b56d2 100644 --- a/src/elements/components/image-carousel-image.module.scss +++ b/src/elements/components/image-carousel-image.module.scss @@ -5,4 +5,11 @@ right: -1px; bottom: -1px; text-align: right; + + // Bounded so a long caption cannot run left into the zoom controls, which + // are anchored in the opposite corner. + max-width: 55%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } From 80e9382169f13eef84e0c568ff43defa2144418d Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Tue, 28 Jul 2026 21:32:40 -0500 Subject: [PATCH 04/38] Fill the model page's content column and make the card grid container-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related layout problems. Most model descriptions are a sentence or two while the metadata sidebar runs the height of the viewport, so the content column ended in a tall void. And the related-model sections sat full-width below both columns, where they read as an afterthought. Moves collections and similar models into the content column, so it always has body and its length tracks the sidebar's. That only works because the card grid no longer keys its column count off the viewport: it used fixed counts at screen-width breakpoints, which would have rendered four cards inside the narrower column and crushed them. It now uses `repeat(auto-fill, minmax(280px, 1fr))`, which is container-relative and reproduces the previous column counts at full page width — verified at 4 columns on the home page at 1440px, 3 in the model page column, 2 at 768px and 1 at 390px. Also drops the "you run it in an upscaling application" explanation. This audience arrives already knowing what a model file is; the two guide links it wrapped now stand on their own. Columns no longer stretch to the row height, so neither pads itself out to match the other. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/model-card-grid.module.scss | 15 +- src/pages/models/[id].tsx | 133 +++++++++--------- 2 files changed, 68 insertions(+), 80 deletions(-) diff --git a/src/elements/components/model-card-grid.module.scss b/src/elements/components/model-card-grid.module.scss index 38b57001..3af98685 100644 --- a/src/elements/components/model-card-grid.module.scss +++ b/src/elements/components/model-card-grid.module.scss @@ -1,15 +1,10 @@ .grid { display: grid; gap: 1rem; - grid-template-columns: repeat(4, minmax(0, 1fr)); - @media screen and (max-width: 1280px) { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - @media screen and (max-width: 896px) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - @media screen and (max-width: 600px) { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } + // Container-relative rather than viewport-relative. The old fixed column + // counts keyed off screen width, so the grid rendered four cards inside a + // narrow column and crushed them. The 280px floor reproduces the previous + // column counts at full page width. + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } diff --git a/src/pages/models/[id].tsx b/src/pages/models/[id].tsx index e3afaf0c..2cca7c5c 100644 --- a/src/pages/models/[id].tsx +++ b/src/pages/models/[id].tsx @@ -341,45 +341,36 @@ function isTrue(value: T | null | undefined | false | '' | 0): value is T { return Boolean(value); } -/** - * Model properties, as a specification list. Labels are quiet micro-type in a - * fixed left column so the values read as the content; previously the labels - * sat in a filled, right-aligned header column that outweighed them. - */ -/** - * Closes the loop after the download button: the visitor now has a file and no - * idea what runs it. Links to the existing how-to rather than restating it. - */ -function UsingThisModel({ archName, scale }: { archName: string; scale: number }) { +/** Quiet pointers to the guides, without restating what this audience knows. */ +function RelatedGuides() { return ( -
-

- Using this model -

-

- This is a {scale}x {archName} model. You run it in an upscaling application such as chaiNNer — the model - file itself is not an executable. -

-
- - How to upscale → - - - Understanding licenses → - -
-
+
+ + How to upscale → + + + Understanding licenses → + +
); } type MetadataRow = false | null | undefined | readonly [string, ReactNode]; +/** + * Model properties, as a specification list. Labels are quiet micro-type in a + * fixed left column so the values read as the content; previously the labels + * sat in a filled, right-aligned header column that outweighed them. + * + * Grouped by kind, because a flat list gave nine training fields the same + * weight as the license. + */ function MetadataTable({ title, rows }: { title?: string; rows: MetadataRow[] }) { const filteredRows = rows.filter(isTrue); if (filteredRows.length === 0) return null; @@ -514,9 +505,9 @@ export default function Page({
{/* Two columns: Description and Sidebar */} -
+
{/* Left column: Description */} -
+
{editMode && ( @@ -644,11 +635,44 @@ export default function Page({ />
- +
+ + {/* Related models live in this column so it always has + body: most descriptions are short, and the sidebar is + long, which otherwise left a tall void beside it. */} + {collections.length > 0 && ( +
+

+ Collections that include this model +

+ +
+ )} + + {similar.length > 0 && ( +
+

Similar models

+ {editMode && similarWithScores.length > 0 && ( +
+ Show scores{' '} +
+                                            {similarWithScores
+                                                .map(({ id, score }) => `${score.toFixed(2).padEnd(6)} ${id}`)
+                                                .join('\n')}
+                                        
+
+ )} + +
+ )}
{/* Right column: Sidebar */}
@@ -853,37 +877,6 @@ export default function Page({
)} - {collections.length > 0 && ( -
-

- Collections that include this model -

- -
- )} - {similar.length > 0 && ( -
-

Similar models

- {editMode && similarWithScores.length > 0 && ( -
- Show scores{' '} -
-                                    {similarWithScores
-                                        .map(({ id, score }) => `${score.toFixed(2).padEnd(6)} ${id}`)
-                                        .join('\n')}
-                                
-
- )} - -
- )}
From 04c0f8e0ca695dcd35fda40c942db98aab819f31 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 00:27:35 -0500 Subject: [PATCH 05/38] Size the model page stage by viewport instead of fixed steps --- .../components/image-carousel.module.scss | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/elements/components/image-carousel.module.scss b/src/elements/components/image-carousel.module.scss index a1dbfdbf..765385a8 100644 --- a/src/elements/components/image-carousel.module.scss +++ b/src/elements/components/image-carousel.module.scss @@ -1,18 +1,19 @@ .imageWrapper { - height: 350px; + // Viewport-relative. The previous fixed 350/450/500px steps meant a 13" + // laptop got a stage that pushed the page off screen while a large monitor + // wasted half of it. + height: clamp(320px, 65vh, 700px); - @media screen and (min-width: 768px) { - height: 450px; - } - - @media screen and (min-width: 1024px) { - height: 500px; - } + @media screen and (max-width: 768px) { + height: clamp(240px, 45vh, 380px); - @media screen and (max-width: 1023px) { + // Full-bleed on narrow screens. Derived from --page-gutter rather than + // a hardcoded 1rem, which silently broke if the gutter changed. border-radius: 0; - margin: -1rem 0 0 -1rem; - width: calc(100% + 2rem); - height: 300px; + margin-left: calc(var(--page-gutter) * -1); + margin-right: calc(var(--page-gutter) * -1); + width: calc(100% + var(--page-gutter) * 2); + border-left: 0; + border-right: 0; } } From ec7b236358052d2cf46b10199ff200277d547e92 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 00:34:03 -0500 Subject: [PATCH 06/38] Hide carousel paging when a model has one image or none --- src/elements/components/image-carousel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/elements/components/image-carousel.tsx b/src/elements/components/image-carousel.tsx index ff904202..4909d43d 100644 --- a/src/elements/components/image-carousel.tsx +++ b/src/elements/components/image-carousel.tsx @@ -60,7 +60,9 @@ export const ImageCarousel = ({ images, readonly, indexKey, onChange }: ImageCar
)}
- {(!readonly || images.length > 1) && ( + {/* Editors always need the strip so they can add and reorder images. + Readers only need it when there is something to page between. */} + {(!readonly || images.length > 1) && images.length > 0 && ( // Centred as one group: the arrows used to be pinned to the far // edges of the page, a long way from the thumbnails they page. // Wraps so the group never outgrows a narrow viewport. From 3f6582de5d5c29db7e4b700cb41312b919ed9d82 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 00:37:18 -0500 Subject: [PATCH 07/38] Revert "Hide carousel paging when a model has one image or none" This reverts commit 775a47572ff2b2e2d838654068ac6632ac1d48d8. --- src/elements/components/image-carousel.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/elements/components/image-carousel.tsx b/src/elements/components/image-carousel.tsx index 4909d43d..ff904202 100644 --- a/src/elements/components/image-carousel.tsx +++ b/src/elements/components/image-carousel.tsx @@ -60,9 +60,7 @@ export const ImageCarousel = ({ images, readonly, indexKey, onChange }: ImageCar
)}
- {/* Editors always need the strip so they can add and reorder images. - Readers only need it when there is something to page between. */} - {(!readonly || images.length > 1) && images.length > 0 && ( + {(!readonly || images.length > 1) && ( // Centred as one group: the arrows used to be pinned to the far // edges of the page, a long way from the thumbnails they page. // Wraps so the group never outgrows a narrow viewport. From 536b04f40822fc520c40f62e941740aea94fdc57 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 00:42:01 -0500 Subject: [PATCH 08/38] Replace the empty-image grey box with a contribution panel --- src/elements/components/image-carousel.tsx | 19 ++++++----- .../model-page/empty-stage.module.scss | 3 ++ .../components/model-page/empty-stage.tsx | 32 +++++++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 src/elements/components/model-page/empty-stage.module.scss create mode 100644 src/elements/components/model-page/empty-stage.tsx diff --git a/src/elements/components/image-carousel.tsx b/src/elements/components/image-carousel.tsx index ff904202..093f124b 100644 --- a/src/elements/components/image-carousel.tsx +++ b/src/elements/components/image-carousel.tsx @@ -15,6 +15,7 @@ import { joinClasses } from '../../lib/util'; import { EditImageButton } from './image-carousel-edit-popover'; import { ImageCarouselImage } from './image-carousel-image'; import style from './image-carousel.module.scss'; +import { EmptyStage } from './model-page/empty-stage'; type ImageCarouselProps = { images: readonly Image[]; @@ -49,17 +50,15 @@ export const ImageCarousel = ({ images, readonly, indexKey, onChange }: ImageCar return (
-
- {selectedImage ? ( + {selectedImage ? ( +
- ) : ( -
-
This model does not have preview images.
-
- )} -
+
+ ) : ( + + )} {(!readonly || images.length > 1) && ( // Centred as one group: the arrows used to be pinned to the far // edges of the page, a long way from the thumbnails they page. diff --git a/src/elements/components/model-page/empty-stage.module.scss b/src/elements/components/model-page/empty-stage.module.scss new file mode 100644 index 00000000..77907f4f --- /dev/null +++ b/src/elements/components/model-page/empty-stage.module.scss @@ -0,0 +1,3 @@ +.panel { + min-height: 15rem; +} diff --git a/src/elements/components/model-page/empty-stage.tsx b/src/elements/components/model-page/empty-stage.tsx new file mode 100644 index 00000000..a157b0f6 --- /dev/null +++ b/src/elements/components/model-page/empty-stage.tsx @@ -0,0 +1,32 @@ +import { joinClasses } from '../../../lib/util'; +import { Link } from '../link'; +import style from './empty-stage.module.scss'; + +/** + * Shown in place of the viewer for the 265 models with no example images. + * Deliberately shorter than the stage: a full-height panel would give those + * pages a whole screen of absence. + */ +export function EmptyStage() { + return ( +
+

No example images yet

+

+ This model doesn't have a before/after example. Adding one helps people judge whether it fits their + material. +

+ + Add an example → + +
+ ); +} From 8e738f16577ed109571ae6af2f762aa1c50c6b7f Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 00:49:48 -0500 Subject: [PATCH 09/38] Collapse training metadata behind a disclosure Move MetadataTable/MetadataRow/isTrue out of the page file into src/elements/components/model-page/metadata-table.tsx so Task 5 can import from it, then wrap the Training rows in a new TrainingDetails disclosure. Training provenance is reference material that 179 of 669 models fill in for only 1-2 of 8 fields, so it now collapses by default and opens automatically in edit mode. --- .../components/model-page/metadata-table.tsx | 50 ++++++++++++++ .../model-page/training-details.tsx | 30 +++++++++ src/pages/models/[id].tsx | 65 ++----------------- 3 files changed, 86 insertions(+), 59 deletions(-) create mode 100644 src/elements/components/model-page/metadata-table.tsx create mode 100644 src/elements/components/model-page/training-details.tsx diff --git a/src/elements/components/model-page/metadata-table.tsx b/src/elements/components/model-page/metadata-table.tsx new file mode 100644 index 00000000..78d09059 --- /dev/null +++ b/src/elements/components/model-page/metadata-table.tsx @@ -0,0 +1,50 @@ +import { ReactNode } from 'react'; +import { joinClasses } from '../../../lib/util'; + +function isTrue(value: T | null | undefined | false | '' | 0): value is T { + return Boolean(value); +} + +export type MetadataRow = false | null | undefined | readonly [string, ReactNode]; + +/** + * Model properties, as a specification list. Labels are quiet micro-type in a + * fixed left column so the values read as the content; previously the labels + * sat in a filled, right-aligned header column that outweighed them. + * + * Grouped by kind, because a flat list gave nine training fields the same + * weight as the license. + */ +export function MetadataTable({ title, rows }: { title?: string; rows: MetadataRow[] }) { + const filteredRows = rows.filter(isTrue); + if (filteredRows.length === 0) return null; + + return ( +
+ {title && ( +

{title}

+ )} +
+ {filteredRows.map((row, i) => { + const [label, value] = row; + return ( +
0 && 'border-x-0 border-t border-b-0 border-solid border-line' + )} + key={i} + > +
+ {label} +
+
{value}
+
+ ); + })} +
+
+ ); +} diff --git a/src/elements/components/model-page/training-details.tsx b/src/elements/components/model-page/training-details.tsx new file mode 100644 index 00000000..f92b5a68 --- /dev/null +++ b/src/elements/components/model-page/training-details.tsx @@ -0,0 +1,30 @@ +import { MetadataRow, MetadataTable } from './metadata-table'; + +interface TrainingDetailsProps { + rows: MetadataRow[]; + editMode: boolean; +} + +/** + * Training provenance is reference material: people rarely choose a model + * because it ran 85,000 iterations. Collapsed by default, but open in edit + * mode so contributors are not fighting a disclosure. + */ +export function TrainingDetails({ rows, editMode }: TrainingDetailsProps) { + const filled = rows.filter(Boolean).length; + if (filled === 0) return null; + + return ( +
+ + Training details ({filled}) + +
+ +
+
+ ); +} diff --git a/src/pages/models/[id].tsx b/src/pages/models/[id].tsx index 2cca7c5c..af68fb50 100644 --- a/src/pages/models/[id].tsx +++ b/src/pages/models/[id].tsx @@ -2,7 +2,7 @@ import { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { ParsedUrlQuery } from 'querystring'; -import React, { ReactNode, useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { AiFillEdit } from 'react-icons/ai'; import { BsFillTrashFill, BsPlusLg } from 'react-icons/bs'; import { DownloadButton } from '../../elements/components/download-button'; @@ -15,6 +15,8 @@ import { ImageCarousel } from '../../elements/components/image-carousel'; import { LicenseAttributes } from '../../elements/components/license-attributes'; import { Link } from '../../elements/components/link'; import { ModelCardGrid } from '../../elements/components/model-card-grid'; +import { MetadataTable } from '../../elements/components/model-page/metadata-table'; +import { TrainingDetails } from '../../elements/components/model-page/training-details'; import { Switch } from '../../elements/components/switch'; import { HeadCommon } from '../../elements/head-common'; import { PageContainer } from '../../elements/page'; @@ -33,15 +35,7 @@ import { getSimilarModels } from '../../lib/similar'; import { IS_DEPLOYED } from '../../lib/site-data'; import { STATIC_ARCH_DATA } from '../../lib/static-data'; import { getTextDescription } from '../../lib/text-description'; -import { - EMPTY_ARRAY, - asArray, - getColorMode, - getPreviewImage, - joinClasses, - joinListString, - typedKeys, -} from '../../lib/util'; +import { EMPTY_ARRAY, asArray, getColorMode, getPreviewImage, joinListString, typedKeys } from '../../lib/util'; import { validateModel } from '../../lib/validate-model'; const MAX_SIMILAR_MODELS = 12 * 2; @@ -337,10 +331,6 @@ function LicenseProp({ model, updateModelProperty, editMode }: PropertyProps) { ); } -function isTrue(value: T | null | undefined | false | '' | 0): value is T { - return Boolean(value); -} - /** Quiet pointers to the guides, without restating what this audience knows. */ function RelatedGuides() { return ( @@ -361,49 +351,6 @@ function RelatedGuides() { ); } -type MetadataRow = false | null | undefined | readonly [string, ReactNode]; - -/** - * Model properties, as a specification list. Labels are quiet micro-type in a - * fixed left column so the values read as the content; previously the labels - * sat in a filled, right-aligned header column that outweighed them. - * - * Grouped by kind, because a flat list gave nine training fields the same - * weight as the license. - */ -function MetadataTable({ title, rows }: { title?: string; rows: MetadataRow[] }) { - const filteredRows = rows.filter(isTrue); - if (filteredRows.length === 0) return null; - - return ( -
- {title && ( -

{title}

- )} -
- {filteredRows.map((row, i) => { - const [label, value] = row; - return ( -
0 && 'border-x-0 border-t border-b-0 border-solid border-line' - )} - key={i} - > -
- {label} -
-
{value}
-
- ); - })} -
-
- ); -} export default function Page({ modelId, staticSimilar, @@ -801,7 +748,8 @@ export default function Page({ title="Rights" /> -
From f85c8b9e870058c6c27b6f6d2b598d8180c8ee44 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 00:59:33 -0500 Subject: [PATCH 10/38] Split model facts and rights into side-by-side spec cards --- .../components/model-page/spec-cards.tsx | 18 +++++++++++ src/pages/models/[id].tsx | 30 +++++++------------ 2 files changed, 28 insertions(+), 20 deletions(-) create mode 100644 src/elements/components/model-page/spec-cards.tsx diff --git a/src/elements/components/model-page/spec-cards.tsx b/src/elements/components/model-page/spec-cards.tsx new file mode 100644 index 00000000..9086e44a --- /dev/null +++ b/src/elements/components/model-page/spec-cards.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react'; +import { MetadataRow, MetadataTable } from './metadata-table'; + +export function SpecCards({ modelRows, rights }: { modelRows: MetadataRow[]; rights: ReactNode }) { + return ( +
+
+

Model

+ +
+ +
+

Rights

+
{rights}
+
+
+ ); +} diff --git a/src/pages/models/[id].tsx b/src/pages/models/[id].tsx index af68fb50..bcb5a59f 100644 --- a/src/pages/models/[id].tsx +++ b/src/pages/models/[id].tsx @@ -15,7 +15,7 @@ import { ImageCarousel } from '../../elements/components/image-carousel'; import { LicenseAttributes } from '../../elements/components/license-attributes'; import { Link } from '../../elements/components/link'; import { ModelCardGrid } from '../../elements/components/model-card-grid'; -import { MetadataTable } from '../../elements/components/model-page/metadata-table'; +import { SpecCards } from '../../elements/components/model-page/spec-cards'; import { TrainingDetails } from '../../elements/components/model-page/training-details'; import { Switch } from '../../elements/components/switch'; import { HeadCommon } from '../../elements/head-common'; @@ -694,8 +694,8 @@ export default function Page({
- - - , - ], - /* eslint-enable react/jsx-key */ - ]} - title="Rights" + rights={ + + } /> Date: Wed, 29 Jul 2026 01:20:34 -0500 Subject: [PATCH 11/38] Extract the downloads block and hide it when there is one resource Move the resources sidebar block into DownloadsBlock, rendering it only for multi-resource models in read mode (9% of models) while always rendering it in edit mode. Fixes a latent bug in the process: the per-resource edit handler replaced entries by loop index, which breaks once resources are skipped; it now matches by sha256 like the delete path already did. --- .../components/model-page/downloads-block.tsx | 76 +++++++++++++++++ src/pages/models/[id].tsx | 81 +++---------------- 2 files changed, 85 insertions(+), 72 deletions(-) create mode 100644 src/elements/components/model-page/downloads-block.tsx diff --git a/src/elements/components/model-page/downloads-block.tsx b/src/elements/components/model-page/downloads-block.tsx new file mode 100644 index 00000000..40b400fb --- /dev/null +++ b/src/elements/components/model-page/downloads-block.tsx @@ -0,0 +1,76 @@ +import { AiFillEdit } from 'react-icons/ai'; +import { BsFillTrashFill } from 'react-icons/bs'; +import { ModelId, Resource } from '../../../lib/schema'; +import { DownloadButton } from '../download-button'; +import { EditResourceButton } from '../download-button-edit-popover'; + +interface DownloadsBlockProps { + resources: readonly Resource[]; + modelId: ModelId; + editMode: boolean; + onChange: (resources: Resource[]) => void; + /** Resources already shown in the identity strip; skipped here in read mode. */ + skip: number; +} + +/** + * Only 9% of models have more than one resource, so in read mode this renders + * for that minority alone. In edit mode it always renders, so resource + * management has one unambiguous home rather than being split with the strip. + */ +export function DownloadsBlock({ resources, modelId, editMode, onChange, skip }: DownloadsBlockProps) { + const shown = editMode ? resources : resources.slice(skip); + if (shown.length === 0) return null; + + const replaceResource = (original: Resource, next: Resource) => + onChange(resources.map((r) => (r.sha256 === original.sha256 ? next : r)).filter((r) => r.urls.length > 0)); + + return ( +
+

+ {editMode ? 'Downloads' : 'Other downloads'} +

+
+ {shown.map((resource) => ( +
+ replaceResource(resource, newResource)} + /> + {editMode && ( + <> + + replaceResource(resource, newResource)} + > + + + + )} +
+ ))} + {editMode && ( + onChange([...resources, newResource])} + > + Add Resource + + )} +
+
+ ); +} diff --git a/src/pages/models/[id].tsx b/src/pages/models/[id].tsx index bcb5a59f..476fe324 100644 --- a/src/pages/models/[id].tsx +++ b/src/pages/models/[id].tsx @@ -5,8 +5,6 @@ import { ParsedUrlQuery } from 'querystring'; import React, { useCallback, useMemo } from 'react'; import { AiFillEdit } from 'react-icons/ai'; import { BsFillTrashFill, BsPlusLg } from 'react-icons/bs'; -import { DownloadButton } from '../../elements/components/download-button'; -import { EditResourceButton } from '../../elements/components/download-button-edit-popover'; import { EditableIntegerLabel, EditableLabel } from '../../elements/components/editable-label'; import { EditableMarkdownContainer } from '../../elements/components/editable-markdown'; import { EditableTags, SmallTag } from '../../elements/components/editable-tags'; @@ -15,6 +13,7 @@ import { ImageCarousel } from '../../elements/components/image-carousel'; import { LicenseAttributes } from '../../elements/components/license-attributes'; import { Link } from '../../elements/components/link'; import { ModelCardGrid } from '../../elements/components/model-card-grid'; +import { DownloadsBlock } from '../../elements/components/model-page/downloads-block'; import { SpecCards } from '../../elements/components/model-page/spec-cards'; import { TrainingDetails } from '../../elements/components/model-page/training-details'; import { Switch } from '../../elements/components/switch'; @@ -28,7 +27,7 @@ import { useUsers } from '../../lib/hooks/use-users'; import { useWebApi } from '../../lib/hooks/use-web-api'; import { KNOWN_LICENSES } from '../../lib/license'; import { MODEL_PROPS, ModelProp } from '../../lib/model-props'; -import { ArchId, Collection, CollectionId, Model, ModelId, Resource, TagId } from '../../lib/schema'; +import { ArchId, Collection, CollectionId, Model, ModelId, TagId } from '../../lib/schema'; import { getCachedCollections, getCachedModels } from '../../lib/server/cached'; import { fileApi } from '../../lib/server/file-data'; import { getSimilarModels } from '../../lib/similar'; @@ -623,75 +622,13 @@ export default function Page({
{/* Right column: Sidebar */}
- {/* Download Button */} -
- {model.resources.length > 1 && ( -

- Downloads -

- )} - {model.resources.map((resource, index) => { - return ( -
- { - const newResources = model.resources - .map((r) => { - if (r.sha256 === resource.sha256) { - return newResource; - } - return r; - }) - .filter((r) => r.urls.length > 0); - updateModelProperty('resources', newResources); - }} - /> - {editMode && ( - <> - - { - const newResources = model.resources; - newResources[index] = newResource; - updateModelProperty('resources', newResources); - }} - > - - - - )} -
- ); - })} - {editMode && ( - { - const newResources = [...model.resources, newResource]; - updateModelProperty('resources', newResources); - }} - > - Add Resource - - )} -
+ updateModelProperty('resources', newResources)} + />
Date: Wed, 29 Jul 2026 01:31:46 -0500 Subject: [PATCH 12/38] Fix: keep the downloads block reachable in edit mode with zero resources The empty-block early return applied to both modes. In edit mode this hid Add Resource for zero-resource models (e.g. models added without a main file via add-model.tsx), the only place in the app that adds a resource - the same regression class already reverted once in this plan (61c561d). Guard now applies to read mode only. --- src/elements/components/model-page/downloads-block.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/elements/components/model-page/downloads-block.tsx b/src/elements/components/model-page/downloads-block.tsx index 40b400fb..c58ce32f 100644 --- a/src/elements/components/model-page/downloads-block.tsx +++ b/src/elements/components/model-page/downloads-block.tsx @@ -20,7 +20,10 @@ interface DownloadsBlockProps { */ export function DownloadsBlock({ resources, modelId, editMode, onChange, skip }: DownloadsBlockProps) { const shown = editMode ? resources : resources.slice(skip); - if (shown.length === 0) return null; + // Read mode only: a model can have zero resources (e.g. added without a main file), + // and edit mode must still render so `Add Resource` — the only place in the app that + // adds a resource — stays reachable. Do not extend this guard to editMode. + if (!editMode && shown.length === 0) return null; const replaceResource = (original: Resource, next: Resource) => onChange(resources.map((r) => (r.sha256 === original.sha256 ? next : r)).filter((r) => r.urls.length > 0)); From 979bafb72c9264e0e883daed1547552d6623c1dc Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 01:45:47 -0500 Subject: [PATCH 13/38] Rebuild the model page as a stage-first single column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the two-column grid. The identity strip (name, scale/arch badges, authors, primary download) and the image stage now run full container width, prose and metadata sit in a centred 56rem reading column, and the collections/similar card grids stay full width below it. The strip replaces the primary resource with the same semantics as DownloadsBlock — match on sha256, drop resources left with no URLs — so both surfaces edit the resource array identically. In edit mode the primary resource deliberately appears in both: the strip is the page's primary action, the block is the management surface that owns Add Resource. Co-Authored-By: Claude Opus 5 (1M context) --- .../model-page/identity-strip.module.scss | 5 + .../components/model-page/identity-strip.tsx | 58 ++ src/pages/models/[id].tsx | 527 +++++++++--------- 3 files changed, 328 insertions(+), 262 deletions(-) create mode 100644 src/elements/components/model-page/identity-strip.module.scss create mode 100644 src/elements/components/model-page/identity-strip.tsx diff --git a/src/elements/components/model-page/identity-strip.module.scss b/src/elements/components/model-page/identity-strip.module.scss new file mode 100644 index 00000000..d0e509d6 --- /dev/null +++ b/src/elements/components/model-page/identity-strip.module.scss @@ -0,0 +1,5 @@ +.action { + @media only screen and (min-width: 640px) { + min-width: 16rem; + } +} diff --git a/src/elements/components/model-page/identity-strip.tsx b/src/elements/components/model-page/identity-strip.tsx new file mode 100644 index 00000000..323fe8fe --- /dev/null +++ b/src/elements/components/model-page/identity-strip.tsx @@ -0,0 +1,58 @@ +import { ReactNode } from 'react'; +import { Model, Resource } from '../../../lib/schema'; +import { joinClasses } from '../../../lib/util'; +import { DownloadButton } from '../download-button'; +import style from './identity-strip.module.scss'; + +interface IdentityStripProps { + model: Model; + archName: string; + /** `EditableLabel` for the model name, supplied by the page. */ + name: ReactNode; + /** `EditableUsers` for the authors, supplied by the page. */ + authors: ReactNode; + editMode: boolean; + onResourceChange: (resource: Resource) => void; +} + +/** + * Model pages are linked directly from Discord, forums and search, so a cold + * visitor needs the name and the primary action before scrolling. Previously + * the name sat below the image. + */ +export function IdentityStrip({ model, archName, name, authors, editMode, onResourceChange }: IdentityStripProps) { + const primary = model.resources[0] as Resource | undefined; + + return ( +
+
+

+ {name} +

+
+ + {model.scale}x + + + {archName} + + · + {authors} +
+
+ + {/* A model can have zero resources (see add-model). The strip is the + primary action, not the management surface — DownloadsBlock owns + adding resources, and renders unconditionally in edit mode. */} + {primary && ( +
+ +
+ )} +
+ ); +} diff --git a/src/pages/models/[id].tsx b/src/pages/models/[id].tsx index 476fe324..dac742d8 100644 --- a/src/pages/models/[id].tsx +++ b/src/pages/models/[id].tsx @@ -14,6 +14,7 @@ import { LicenseAttributes } from '../../elements/components/license-attributes' import { Link } from '../../elements/components/link'; import { ModelCardGrid } from '../../elements/components/model-card-grid'; import { DownloadsBlock } from '../../elements/components/model-page/downloads-block'; +import { IdentityStrip } from '../../elements/components/model-page/identity-strip'; import { SpecCards } from '../../elements/components/model-page/spec-cards'; import { TrainingDetails } from '../../elements/components/model-page/training-details'; import { Switch } from '../../elements/components/switch'; @@ -27,7 +28,7 @@ import { useUsers } from '../../lib/hooks/use-users'; import { useWebApi } from '../../lib/hooks/use-web-api'; import { KNOWN_LICENSES } from '../../lib/license'; import { MODEL_PROPS, ModelProp } from '../../lib/model-props'; -import { ArchId, Collection, CollectionId, Model, ModelId, TagId } from '../../lib/schema'; +import { ArchId, Collection, CollectionId, Model, ModelId, Resource, TagId } from '../../lib/schema'; import { getCachedCollections, getCachedModels } from '../../lib/server/cached'; import { fileApi } from '../../lib/server/file-data'; import { getSimilarModels } from '../../lib/similar'; @@ -390,6 +391,20 @@ export default function Page({ const router = useRouter(); + /** + * The identity strip shows the primary resource and `DownloadsBlock` shows the + * same array, so both must replace a resource identically: match on `sha256` + * and drop any resource left with no URLs. + */ + const replacePrimaryResource = (newResource: Resource) => { + const primary = model.resources[0] as Resource | undefined; + if (!primary) return; + updateModelProperty( + 'resources', + model.resources.map((r) => (r.sha256 === primary.sha256 ? newResource : r)).filter((r) => r.urls.length > 0) + ); + }; + const runModelValidation = useCallback(async () => { if (!webApi) { throw new Error('API not available'); @@ -440,7 +455,108 @@ export default function Page({
- {/* Full-width preview at top (YouTube-style) */} + {editMode && ( +
+ + {IS_DEPLOYED && ( + <> + + + + + )} +
+ )} + + { + updateModelProperty('author', users.length === 1 ? users[0] : users); + }} + /> + } + editMode={editMode} + model={model} + name={ + updateModelProperty('name', value)} + /> + } + onResourceChange={replacePrimaryResource} + /> + + {/* Full-width preview (YouTube-style) */}
- {/* Two columns: Description and Sidebar */} -
- {/* Left column: Description */} -
-
-
- {editMode && ( -
- - {IS_DEPLOYED && ( - <> - - - - - )} -
- )} -

- updateModelProperty('name', value)} - /> -

-
- - {model.scale}x - - - {archName} - - · - { - updateModelProperty('author', users.length === 1 ? users[0] : users); - }} - /> -
-
-
-

- Good for -

-
- updateModelProperty('tags', tags)} - /> -
-
-
- updateModelProperty('description', value)} - /> -
- - + {/* Reading column: capped so prose keeps a comfortable measure + instead of stretching the full container width. */} +
+
+

+ Good for +

+
+ updateModelProperty('tags', tags)} + />
- - {/* Related models live in this column so it always has - body: most descriptions are short, and the sidebar is - long, which otherwise left a tall void beside it. */} - {collections.length > 0 && ( -
-

- Collections that include this model -

- -
- )} - - {similar.length > 0 && ( -
-

Similar models

- {editMode && similarWithScores.length > 0 && ( -
- Show scores{' '} -
-                                            {similarWithScores
-                                                .map(({ id, score }) => `${score.toFixed(2).padEnd(6)} ${id}`)
-                                                .join('\n')}
-                                        
-
- )} - -
- )}
- {/* Right column: Sidebar */} -
- updateModelProperty('resources', newResources)} - /> -
- , - ], - [ - 'Scale', - , - ], - (model.size || editMode) && [ - 'Size', - renderTags(model.size ?? EMPTY_ARRAY, editMode, (newTags: string[]) => { - updateModelProperty('size', newTags.length === 0 ? null : newTags); - }), - ], - [ - 'Color Mode', - , - ], - /* eslint-enable react/jsx-key */ - ]} - rights={ - - } - /> + {/* `|| editMode` keeps the description editor reachable on the + 20 models that have none. Do not simplify it away. */} + {(model.description || editMode) && ( + updateModelProperty('description', value)} + /> + )} - , + ], + [ + 'Scale', + , + ], + (model.size || editMode) && [ + 'Size', + renderTags(model.size ?? EMPTY_ARRAY, editMode, (newTags: string[]) => { + updateModelProperty('size', newTags.length === 0 ? null : newTags); + }), + ], + [ + 'Color Mode', + , + ], + /* eslint-enable react/jsx-key */ + ]} + rights={ + { - return [ - 'date', - 'dataset', - 'datasetSize', - 'trainingIterations', - 'trainingEpochs', - 'trainingBatchSize', - 'trainingHRSize', - 'trainingOTF', - ].includes(key); - }) - .map((key) => [key, model[key]] as const) - .filter(([, value]) => editMode || value != null) - .map(([key, value]) => { - const prop = MODEL_PROPS[key]; - return [ - prop.name, - Array.isArray(value) - ? renderTags( - value.map((v) => String(v)), - editMode, - (newTags) => { - updateModelProperty(key, newTags); - } - ) - : editableMetadata(editMode, value, prop, (newValue) => { - updateModelProperty(key, newValue); - }), - ] as const; - }), - /* eslint-enable react/jsx-key */ - ]} + model={model} + updateModelProperty={updateModelProperty} /> -
-
+ } + /> + + updateModelProperty('resources', newResources)} + /> + + { + return [ + 'date', + 'dataset', + 'datasetSize', + 'trainingIterations', + 'trainingEpochs', + 'trainingBatchSize', + 'trainingHRSize', + 'trainingOTF', + ].includes(key); + }) + .map((key) => [key, model[key]] as const) + .filter(([, value]) => editMode || value != null) + .map(([key, value]) => { + const prop = MODEL_PROPS[key]; + return [ + prop.name, + Array.isArray(value) + ? renderTags( + value.map((v) => String(v)), + editMode, + (newTags) => { + updateModelProperty(key, newTags); + } + ) + : editableMetadata(editMode, value, prop, (newValue) => { + updateModelProperty(key, newValue); + }), + ] as const; + }), + /* eslint-enable react/jsx-key */ + ]} + /> + +
+ + {collections.length > 0 && ( +
+

+ Collections that include this model +

+ +
+ )} + + {similar.length > 0 && ( +
+

Similar models

+ {editMode && similarWithScores.length > 0 && ( +
+ Show scores{' '} +
+                                    {similarWithScores
+                                        .map(({ id, score }) => `${score.toFixed(2).padEnd(6)} ${id}`)
+                                        .join('\n')}
+                                
+
+ )} + +
+ )} + {editMode && (
- {editMode && ( -
- - {IS_DEPLOYED && ( - <> - - - - - )} -
- )} - - { - updateModelProperty('author', users.length === 1 ? users[0] : users); - }} - /> - } - editMode={editMode} - model={model} - name={ - updateModelProperty('name', value)} - /> - } - onResourceChange={replacePrimaryResource} - /> - - {/* Full-width preview (YouTube-style) */} + {/* Full-width preview at top (YouTube-style) */}
- {/* Reading column: capped so prose keeps a comfortable measure - instead of stretching the full container width. */} -
-
-

- Good for -

-
- updateModelProperty('tags', tags)} - /> + {/* Two columns: Description and Sidebar */} +
+ {/* Left column: Description */} +
+
+
+ {editMode && ( +
+ + {IS_DEPLOYED && ( + <> + + + + + )} +
+ )} +

+ updateModelProperty('name', value)} + /> +

+
+ + {model.scale}x + + + {archName} + + · + { + updateModelProperty('author', users.length === 1 ? users[0] : users); + }} + /> +
+
+
+

+ Good for +

+
+ updateModelProperty('tags', tags)} + /> +
+
+
+ updateModelProperty('description', value)} + /> +
+ +
-
- {/* `|| editMode` keeps the description editor reachable on the - 20 models that have none. Do not simplify it away. */} - {(model.description || editMode) && ( - updateModelProperty('description', value)} + {/* Related models live in this column so it always has + body: most descriptions are short, and the sidebar is + long, which otherwise left a tall void beside it. */} + {collections.length > 0 && ( +
+

+ Collections that include this model +

+ +
+ )} + + {similar.length > 0 && ( +
+

Similar models

+ {editMode && similarWithScores.length > 0 && ( +
+ Show scores{' '} +
+                                            {similarWithScores
+                                                .map(({ id, score }) => `${score.toFixed(2).padEnd(6)} ${id}`)
+                                                .join('\n')}
+                                        
+
+ )} + +
+ )} +
+ {/* Right column: Sidebar */} +
+ updateModelProperty('resources', newResources)} /> - )} - , - ], - [ - 'Scale', - , - ], - (model.size || editMode) && [ - 'Size', - renderTags(model.size ?? EMPTY_ARRAY, editMode, (newTags: string[]) => { - updateModelProperty('size', newTags.length === 0 ? null : newTags); - }), - ], - [ - 'Color Mode', - , - ], - /* eslint-enable react/jsx-key */ - ]} - rights={ - + , + ], + [ + 'Scale', + , + ], + (model.size || editMode) && [ + 'Size', + renderTags(model.size ?? EMPTY_ARRAY, editMode, (newTags: string[]) => { + updateModelProperty('size', newTags.length === 0 ? null : newTags); + }), + ], + [ + 'Color Mode', + , + ], + /* eslint-enable react/jsx-key */ + ]} + rights={ + + } /> - } - /> - - updateModelProperty('resources', newResources)} - /> - - { - return [ - 'date', - 'dataset', - 'datasetSize', - 'trainingIterations', - 'trainingEpochs', - 'trainingBatchSize', - 'trainingHRSize', - 'trainingOTF', - ].includes(key); - }) - .map((key) => [key, model[key]] as const) - .filter(([, value]) => editMode || value != null) - .map(([key, value]) => { - const prop = MODEL_PROPS[key]; - return [ - prop.name, - Array.isArray(value) - ? renderTags( - value.map((v) => String(v)), - editMode, - (newTags) => { - updateModelProperty(key, newTags); - } - ) - : editableMetadata(editMode, value, prop, (newValue) => { - updateModelProperty(key, newValue); - }), - ] as const; - }), - /* eslint-enable react/jsx-key */ - ]} - /> - -
- - {collections.length > 0 && ( -
-

- Collections that include this model -

- -
- )} - - {similar.length > 0 && ( -
-

Similar models

- {editMode && similarWithScores.length > 0 && ( -
- Show scores{' '} -
-                                    {similarWithScores
-                                        .map(({ id, score }) => `${score.toFixed(2).padEnd(6)} ${id}`)
-                                        .join('\n')}
-                                
-
- )} - + { + return [ + 'date', + 'dataset', + 'datasetSize', + 'trainingIterations', + 'trainingEpochs', + 'trainingBatchSize', + 'trainingHRSize', + 'trainingOTF', + ].includes(key); + }) + .map((key) => [key, model[key]] as const) + .filter(([, value]) => editMode || value != null) + .map(([key, value]) => { + const prop = MODEL_PROPS[key]; + return [ + prop.name, + Array.isArray(value) + ? renderTags( + value.map((v) => String(v)), + editMode, + (newTags) => { + updateModelProperty(key, newTags); + } + ) + : editableMetadata(editMode, value, prop, (newValue) => { + updateModelProperty(key, newValue); + }), + ] as const; + }), + /* eslint-enable react/jsx-key */ + ]} + /> +
- )} - +
{editMode && (
From df37b09404ea1fc7d8900211299d9f2375da12b8 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 02:08:00 -0500 Subject: [PATCH 16/38] Fold the thumbnail rail into the stage card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail sat on bare page background below a bordered image box, so the space between them read as a gap rather than as the image's controls. The card chrome moves from the image box to a wrapper holding both, so the stage is one bordered surface containing the image area and the rail, divided by a hairline. The rail keeps no background of its own — it inherits the stage's, which is what makes it read as part of the same object. `EmptyStage` drops its own border, radius and background for the same reason: it now renders inside the stage card, and keeping them would have nested a card inside an identical card on the 265 models with no images. The mobile full-bleed moves to the wrapper with the rest of the chrome; verified at 390px that margin-left still resolves to -16px from --page-gutter, the side borders and radius still drop, and the rail rides along inside it. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/image-carousel.module.scss | 23 +- src/elements/components/image-carousel.tsx | 309 +++++++++--------- .../components/model-page/empty-stage.tsx | 5 +- 3 files changed, 177 insertions(+), 160 deletions(-) diff --git a/src/elements/components/image-carousel.module.scss b/src/elements/components/image-carousel.module.scss index 765385a8..12905d8e 100644 --- a/src/elements/components/image-carousel.module.scss +++ b/src/elements/components/image-carousel.module.scss @@ -1,12 +1,8 @@ -.imageWrapper { - // Viewport-relative. The previous fixed 350/450/500px steps meant a 13" - // laptop got a stage that pushed the page off screen while a large monitor - // wasted half of it. - height: clamp(320px, 65vh, 700px); - +// The stage is one card containing the image area and the thumbnail rail. +// They used to be separate: the rail sat on bare page background below a +// bordered image box, which read as a gap rather than as its controls. +.stage { @media screen and (max-width: 768px) { - height: clamp(240px, 45vh, 380px); - // Full-bleed on narrow screens. Derived from --page-gutter rather than // a hardcoded 1rem, which silently broke if the gutter changed. border-radius: 0; @@ -17,3 +13,14 @@ border-right: 0; } } + +.imageArea { + // Viewport-relative. The previous fixed 350/450/500px steps meant a 13" + // laptop got a stage that pushed the page off screen while a large monitor + // wasted half of it. + height: clamp(320px, 65vh, 700px); + + @media screen and (max-width: 768px) { + height: clamp(240px, 45vh, 380px); + } +} diff --git a/src/elements/components/image-carousel.tsx b/src/elements/components/image-carousel.tsx index 093f124b..337a802a 100644 --- a/src/elements/components/image-carousel.tsx +++ b/src/elements/components/image-carousel.tsx @@ -50,165 +50,174 @@ export const ImageCarousel = ({ images, readonly, indexKey, onChange }: ImageCar return (
- {selectedImage ? ( -
- -
- ) : ( - - )} - {(!readonly || images.length > 1) && ( - // Centred as one group: the arrows used to be pinned to the far - // edges of the page, a long way from the thumbnails they page. - // Wraps so the group never outgrows a narrow viewport. -
- {images.length >= 2 && ( - - )} -
- {sliceStartIndex > 0 && ( -
- -
+
+ {selectedImage ? ( +
+ +
+ ) : ( + + )} + {(!readonly || images.length > 1) && ( + // Centred as one group: the arrows used to be pinned to the far + // edges of the page, a long way from the thumbnails they page. + // Wraps so the group never outgrows a narrow viewport. + // Sits inside the stage card so it reads as the image's controls + // rather than as a detached row floating below it. +
+ {images.length >= 2 && ( + )} - {images.slice(sliceStartIndex, sliceEndIndex).map((image, actualIndex) => { - const index = sliceStartIndex + actualIndex; - return ( -
- {!readonly && ( -
- { - const newImages = [...images]; - newImages[index] = editedImage; - if (onChange) { - onChange(newImages); - } - }} - > - - - -
- )} +
+ {sliceStartIndex > 0 && ( +
+ +
+ )} + {images.slice(sliceStartIndex, sliceEndIndex).map((image, actualIndex) => { + const index = sliceStartIndex + actualIndex; + return (
{ - setImageIndex(index); - }} + className="flex flex-col items-center" + key={image.type === 'paired' ? image.SR : image.url} > - {/* eslint-disable-next-line @next/next/no-img-element */} - {image.caption -
- {!readonly && ( -
- - - + {!readonly && ( +
+ { + const newImages = [...images]; + newImages[index] = editedImage; + if (onChange) { + onChange(newImages); + } + }} + > + + + +
+ )} +
{ + setImageIndex(index); + }} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {image.caption
- )} + {!readonly && ( +
+ + + +
+ )} +
+ ); + })} + {sliceEndIndex < images.length && ( +
+
- ); - })} - {sliceEndIndex < images.length && ( -
- -
- )} - {!readonly && ( - { - const newImages = [...images]; - newImages.push(newImage); - if (onChange) { - onChange(newImages); - } + )} + {!readonly && ( + { + const newImages = [...images]; + newImages.push(newImage); + if (onChange) { + onChange(newImages); + } + }} + > + + + )} +
+ {images.length >= 2 && ( + + )} + {images.length >= 2 && ( + + {imageIndex + 1} / {images.length} + )}
- {images.length >= 2 && ( - - )} - {images.length >= 2 && ( - - {imageIndex + 1} / {images.length} - - )} -
- )} + )} +
); }; diff --git a/src/elements/components/model-page/empty-stage.tsx b/src/elements/components/model-page/empty-stage.tsx index a157b0f6..7cf9bba8 100644 --- a/src/elements/components/model-page/empty-stage.tsx +++ b/src/elements/components/model-page/empty-stage.tsx @@ -12,8 +12,9 @@ export function EmptyStage() {

No example images yet

From fb69452fe4e483c70831c785b38e16c0ed9fd9b0 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 02:12:11 -0500 Subject: [PATCH 17/38] Constrain images and wide blocks inside rendered markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descriptions can embed images, and 16 models do. Nothing was scaling them: Tailwind Preflight is disabled in this project, so the usual global `img { max-width: 100% }` does not exist anywhere. A 1440px-wide comparison strip rendered at full size, ran out of the description column and under the metadata sidebar. Adds `max-width: 100%; height: auto` for `img` and `video` inside rendered markdown, keeping aspect ratio while fitting the column. Also gives `pre` and `table` `overflow-x: auto` for the same class of problem — one model already has a markdown table, and a wide one would otherwise push the column open rather than scrolling within itself. Scoped to the markdown container rather than set globally, because the image carousel sizes and transforms its own images deliberately. Co-Authored-By: Claude Opus 5 (1M context) --- src/elements/markdown.module.scss | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/elements/markdown.module.scss b/src/elements/markdown.module.scss index f21203a3..b1558de2 100644 --- a/src/elements/markdown.module.scss +++ b/src/elements/markdown.module.scss @@ -2,6 +2,24 @@ word-break: normal; overflow-wrap: anywhere; + // Tailwind Preflight is disabled in this project, so nothing supplies the + // usual `img { max-width: 100% }`. Description images are author-supplied + // and routinely far wider than the column — a 2048px comparison strip used + // to run straight out of the column and under the sidebar. + img, + video { + max-width: 100%; + height: auto; + } + + // Same problem, different element: a wide table or a long code block scrolls + // within its own box rather than pushing the column open. + pre, + table { + max-width: 100%; + overflow-x: auto; + } + h2 { margin-top: 1.5em; margin-bottom: 0.75em; From f4413c0c3623051a632d36b5ae82d7dd88c28a23 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 13:58:31 -0500 Subject: [PATCH 18/38] Stop the filter group labels from breaking the heading outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Showing" / "Subject" / "Purpose" labels were

elements sitting directly under the page

, so the home page outline read H1 -> H4 -> H2. They label a set of filter controls rather than a section of the document, so they are now plain spans and the surrounding div is a labelled role="group" — which is the accurate semantics and also announces the grouping that the headings never did. No visual change: .group is a grid, so the spans blockify exactly as the headings did. Co-Authored-By: Claude Opus 5 (1M context) --- src/elements/tag-selector.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/elements/tag-selector.tsx b/src/elements/tag-selector.tsx index c342a4da..da08273e 100644 --- a/src/elements/tag-selector.tsx +++ b/src/elements/tag-selector.tsx @@ -206,8 +206,20 @@ function SimpleTagSelector({ selection, onChange }: TagSelectorProps) { return (
-
-

Showing

+
+ {/* Deliberately not a heading: these label a set of filter controls + rather than a section of the document. As an

under the page's +

they broke the heading outline for screen readers. */} + + Showing +
(
-

{name}

+ + {name} +
{tags.map(([tagId, tag]) => ( Date: Wed, 29 Jul 2026 14:14:13 -0500 Subject: [PATCH 19/38] Restore the vertical page padding the shorthand was eating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.main` set `padding: 0 var(--page-gutter)`, and the shorthand also sets padding-top/bottom to 0. Because this module is injected after Tailwind's utilities, that silently beat the `py-4` on the markup — so every page not using `.padded` (model page, docs, palette, collections) rendered with no vertical padding at all, sitting 2px under the 66px fixed header. `.main` now uses padding-left/right longhand and owns no vertical rhythm, and the non-wrapper branch uses a real `.standard` class instead of the dead utility. The model page's "All models" link now clears the header by 26px instead of 2px, and those pages get their bottom padding back. `.padded` pages are unchanged: 24px top, 40px bottom, verified on /. Co-Authored-By: Claude Opus 5 (1M context) --- src/elements/page.module.scss | 17 ++++++++++++++++- src/elements/page.tsx | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/elements/page.module.scss b/src/elements/page.module.scss index e5ab0980..30e2a335 100644 --- a/src/elements/page.module.scss +++ b/src/elements/page.module.scss @@ -10,7 +10,14 @@ box-sizing: border-box; width: 100%; margin: 0 auto; - padding: 0 var(--page-gutter); + + // Longhand rather than the `padding` shorthand. The shorthand also sets + // padding-top/bottom to 0, and since this module is injected after + // Tailwind's utilities it silently beat the `py-4` the markup relied on — + // so every page that isn't `.padded` rendered flush against the fixed + // header. Keep vertical rhythm out of this rule entirely. + padding-left: var(--page-gutter); + padding-right: var(--page-gutter); flex: 1 0 auto; } @@ -19,3 +26,11 @@ padding-top: var(--stack); padding-bottom: var(--stack-loose); } + +// Pages that lay out their own content directly rather than in a column — +// the model page, docs, palette. Same rhythm as `.padded`; kept separate so +// the two can diverge without one silently restyling the other. +.standard { + padding-top: var(--stack); + padding-bottom: var(--stack-loose); +} diff --git a/src/elements/page.tsx b/src/elements/page.tsx index 06c67fbe..e0fe9de1 100644 --- a/src/elements/page.tsx +++ b/src/elements/page.tsx @@ -15,7 +15,7 @@ export function PageContainer({ children, scrollToTop, wrapper, searchBar }: Pag return (
-
{children}
+
{children}
{scrollToTop && }
From 6058db7eb9407d48c0b904044f52009b058cbd04 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 14:40:08 -0500 Subject: [PATCH 20/38] Add a fullscreen viewer and move zoom into the carousel toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail below the image becomes a three-part toolbar: zoom docked left, thumbnails centred, fullscreen right. Fullscreen keeps the toolbar so images stay switchable without leaving it. Two things worth knowing: The zoom controls are portaled into the toolbar rather than having their state lifted into the carousel. `onTransformed` fires every frame of a pan or zoom, so hoisting the scale would re-render the whole stage — including the TransformWrapper being dragged. The portal moves only where the controls land in the DOM; their state and render cost are unchanged. The rail used to render only when `!readonly || images.length > 1`. Zoom now lives there, so that condition would have removed zoom from every single-image model — the common case. It now governs just the thumbnails, and the toolbar renders whenever there is an image. Fullscreen is keyed off an attribute React sets from `fullscreenchange` rather than the `:fullscreen` pseudo-class: one source of truth, no prefixed selector for older Safari, and the state can be exercised in a test. The toolbar takes layout space there instead of floating, so the caption pinned to the same corner can't end up behind it. Also unifies the image caption with the Before/After chips — it was a corner-pinned gradient banner at 1.125rem next to small inset chips — and sets box-sizing on the toolbar, since Preflight is off and `w-full` plus `px-3` otherwise pushes it wider than the stage, which clips it. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/carousel/chrome-context.tsx | 32 ++ .../carousel/viewer-chrome.module.scss | 80 ++++ .../components/carousel/viewer-chrome.tsx | 68 ++- .../image-carousel-image.module.scss | 21 +- .../components/image-carousel-image.tsx | 8 +- .../components/image-carousel.module.scss | 82 ++++ src/elements/components/image-carousel.tsx | 396 +++++++++++------- 7 files changed, 506 insertions(+), 181 deletions(-) create mode 100644 src/elements/components/carousel/chrome-context.tsx diff --git a/src/elements/components/carousel/chrome-context.tsx b/src/elements/components/carousel/chrome-context.tsx new file mode 100644 index 00000000..5c2b1845 --- /dev/null +++ b/src/elements/components/carousel/chrome-context.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext } from 'react'; + +export interface CarouselChrome { + /** + * Element the viewer should portal its zoom controls into, or null to + * render them overlaying the image. + * + * A portal rather than lifted state on purpose. `onTransformed` fires on + * every frame of a pan or zoom, so hoisting the scale into the carousel + * re-renders the whole stage — including the TransformWrapper being + * dragged — and the viewer fights itself. Portaling moves only where the + * controls land in the DOM; they stay inside the viewer's React subtree, + * so its state and render cost are exactly what they were. + */ + zoomSlot: HTMLElement | null; + + /** + * True while the toolbar floats over the image itself, which it does in + * fullscreen so the picture gets the whole screen. Theme tokens are + * invisible against arbitrary photo pixels, so the controls switch back to + * white-on-scrim there. + */ + overlay: boolean; +} + +const CarouselChromeContext = createContext({ zoomSlot: null, overlay: false }); + +export const CarouselChromeProvider = CarouselChromeContext.Provider; + +export function useCarouselChrome(): CarouselChrome { + return useContext(CarouselChromeContext); +} diff --git a/src/elements/components/carousel/viewer-chrome.module.scss b/src/elements/components/carousel/viewer-chrome.module.scss index 6f0d1bb4..c215086c 100644 --- a/src/elements/components/carousel/viewer-chrome.module.scss +++ b/src/elements/components/carousel/viewer-chrome.module.scss @@ -120,3 +120,83 @@ margin: 0 0.125rem; background-color: rgb(255 255 255 / 25%); } + +// Docked variants: the same controls sitting in the carousel toolbar rather +// than on top of an image. Nothing is overlaying arbitrary pixels any more, so +// the dark scrim and forced white come off and the theme tokens take over. +.bottomBarDocked { + position: static; + max-width: none; +} + +.controlsDocked { + padding: 0; + border-radius: 0; + background-color: transparent; + backdrop-filter: none; +} + +.buttonDocked { + color: var(--font-color-light); + + &:hover { + background-color: var(--surface-hover); + color: var(--font-color); + } + + &:focus-visible { + outline-color: var(--accent-text); + } +} + +.readoutDocked { + color: var(--font-color-light); +} + +.dividerDocked { + background-color: var(--line-color); +} + +// Mirrors .button, but themed for the toolbar it lives in rather than for +// overlaying an image. +.fullscreenButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + border-radius: 0.375rem; + background-color: transparent; + color: var(--font-color-light); + font: inherit; + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover { + background-color: var(--surface-hover); + color: var(--font-color); + } + + &:focus-visible { + outline: 2px solid var(--accent-text); + outline-offset: -2px; + } +} + +// In fullscreen the toolbar floats on the image, so this matches .button. +.fullscreenButtonOverlay { + color: white; + + &:hover { + background-color: rgb(255 255 255 / 18%); + color: white; + } + + &:focus-visible { + outline-color: white; + } +} diff --git a/src/elements/components/carousel/viewer-chrome.tsx b/src/elements/components/carousel/viewer-chrome.tsx index 9591c956..b4c15575 100644 --- a/src/elements/components/carousel/viewer-chrome.tsx +++ b/src/elements/components/carousel/viewer-chrome.tsx @@ -1,6 +1,8 @@ -import { MdAdd, MdRemove } from 'react-icons/md'; +import { createPortal } from 'react-dom'; +import { MdAdd, MdFullscreen, MdFullscreenExit, MdRemove } from 'react-icons/md'; import { useIsTouch } from '../../../lib/hooks/use-is-touch'; import { joinClasses } from '../../../lib/util'; +import { useCarouselChrome } from './chrome-context'; import style from './viewer-chrome.module.scss'; /** @@ -29,19 +31,26 @@ interface ZoomControlsProps { * gesture and gives it visible controls, which also makes the viewer usable * without a scroll wheel. * - * Anchored bottom-left as one group. Image captions are pinned to the - * bottom-right corner, and plenty of example images have their own text baked - * into that corner too, so the controls must stay out of it. + * Normally these dock into the carousel's toolbar, which keeps them off the + * image entirely. In fullscreen there is no toolbar, so they overlay the image + * anchored bottom-left — image captions own the bottom-right corner, and + * plenty of example images have their own text baked into it too. */ export function ZoomControls({ scale, onZoomIn, onZoomOut, onReset }: ZoomControlsProps) { const isTouch = useIsTouch(); + const { zoomSlot, overlay } = useCarouselChrome(); + const docked = zoomSlot !== null; + // Themed only when docked into a toolbar that sits on a real surface. + // Floating over image pixels — either as the fallback below or as the + // fullscreen toolbar — keeps the original white-on-scrim treatment. + const themed = docked && !overlay; - return ( -
-
+ const controls = ( +
+
{Math.round(scale * 100)}%
- {isTouch ? 'Pinch to zoom' : 'Scroll to zoom'} · Drag to pan + {!docked && ( + {isTouch ? 'Pinch to zoom' : 'Scroll to zoom'} · Drag to pan + )}
); + + return zoomSlot ? createPortal(controls, zoomSlot) : controls; +} + +/** + * Sits at the toolbar's right edge and toggles both ways. Rendered by the + * carousel rather than the viewer, because the element that goes fullscreen is + * the stage that contains both of them. + */ +export function FullscreenButton({ + isFullscreen, + overlay, + onClick, +}: { + isFullscreen: boolean; + overlay: boolean; + onClick: () => void; +}) { + const label = isFullscreen ? 'Exit fullscreen' : 'View fullscreen'; + + return ( + + ); } diff --git a/src/elements/components/image-carousel-image.module.scss b/src/elements/components/image-carousel-image.module.scss index 7d4b56d2..4e12dc3f 100644 --- a/src/elements/components/image-carousel-image.module.scss +++ b/src/elements/components/image-carousel-image.module.scss @@ -1,13 +1,22 @@ +// Matches the Before/After chips in carousel/viewer-chrome.module.scss: same +// scrim, radius and inset from the corner. It used to be a corner-pinned +// diagonal-gradient banner at 1.125rem, which read as a different era of the +// design sitting on the same image as those chips. .caption { + right: 0.625rem; + bottom: 0.75rem; + padding: 0.2rem 0.5rem; + border-radius: 0.375rem; + background-color: rgb(17 17 23 / 65%); + backdrop-filter: blur(4px); color: white; - text-shadow: 0 0 5px black, 0 0 5px black; - background: linear-gradient(-45deg, rgb(0 0 0 / 90%), rgb(0 0 0 / 60%)); - right: -1px; - bottom: -1px; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.4; text-align: right; - // Bounded so a long caption cannot run left into the zoom controls, which - // are anchored in the opposite corner. + // Bounded so a long caption cannot run left into the zoom controls when + // those are overlaying the image rather than docked in the toolbar. max-width: 55%; overflow: hidden; text-overflow: ellipsis; diff --git a/src/elements/components/image-carousel-image.tsx b/src/elements/components/image-carousel-image.tsx index c1fdcdb1..2c4c86c5 100644 --- a/src/elements/components/image-carousel-image.tsx +++ b/src/elements/components/image-carousel-image.tsx @@ -27,11 +27,9 @@ export const ImageCarouselImage = ({ image }: ImageCarouselImageProps) => { return (
{inner}
- - {caption} - + {/* Sizing and padding live in the module so this chip stays in step + with the Before/After labels it sits alongside. */} + {caption}
); } else { diff --git a/src/elements/components/image-carousel.module.scss b/src/elements/components/image-carousel.module.scss index 12905d8e..1db9d7f1 100644 --- a/src/elements/components/image-carousel.module.scss +++ b/src/elements/components/image-carousel.module.scss @@ -24,3 +24,85 @@ height: clamp(240px, 45vh, 380px); } } + +// Zoom controls dock left, thumbnails centre, fullscreen sits right. +// `1fr auto 1fr` rather than a flex row so the thumbnails stay centred on the +// stage regardless of how wide the two control clusters are. +.toolbar { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.5rem; + + // Tailwind Preflight is disabled, so box-sizing is the CSS default of + // content-box. Without this the horizontal padding is added *outside* the + // grid's width and the fullscreen button overflows the stage, which clips + // it — the stage sets overflow: hidden for its rounded corners. + box-sizing: border-box; +} + +.toolbarStart { + display: flex; + justify-content: flex-start; + min-width: 0; +} + +.toolbarEnd { + display: flex; + justify-content: flex-end; + min-width: 0; +} + +.toolbarCenter { + min-width: 0; +} + +@media screen and (max-width: 640px) { + // Three columns can't hold the thumbnails and both control clusters at + // phone width, so the thumbnails take a row of their own above them. + .toolbar { + grid-template-columns: 1fr 1fr; + row-gap: 0.625rem; + } + + .toolbarCenter { + grid-row: 1; + grid-column: 1 / -1; + } + + .toolbarStart, + .toolbarEnd { + grid-row: 2; + } +} + +// The stage itself is the fullscreen element, so it carries both the image and +// the toolbar, and the thumbnails stay reachable without leaving fullscreen. +// Keyed off the attribute React sets from `fullscreenchange` rather than the +// `:fullscreen` pseudo-class: one source of truth, no prefixed selector needed +// for older Safari, and the state can actually be exercised in a test. +.stage[data-fullscreen] { + display: flex; + height: 100vh; + flex-direction: column; + border: 0; + border-radius: 0; + background-color: black; + + // The toolbar takes real layout space rather than floating over the image. + // Floating it looked tidier but put the bar on top of the caption, which is + // pinned to the same bottom corner — and any future bottom-anchored chrome + // would have hit the same wall. Giving it its own row costs the picture the + // bar's height and makes the whole class of occlusion bugs impossible. + .imageArea { + height: auto; + flex: 1; + min-height: 0; + } + + .toolbar { + flex: none; + border-top-color: rgb(255 255 255 / 12%); + background-color: rgb(23 23 30); + } +} diff --git a/src/elements/components/image-carousel.tsx b/src/elements/components/image-carousel.tsx index 337a802a..e325fbec 100644 --- a/src/elements/components/image-carousel.tsx +++ b/src/elements/components/image-carousel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AiFillEdit } from 'react-icons/ai'; import { BsCaretLeftFill, @@ -12,6 +12,8 @@ import { FiMoreHorizontal } from 'react-icons/fi'; import { useWindowSize } from '../../lib/hooks/use-window-size'; import { Image } from '../../lib/schema'; import { joinClasses } from '../../lib/util'; +import { CarouselChromeProvider } from './carousel/chrome-context'; +import { FullscreenButton } from './carousel/viewer-chrome'; import { EditImageButton } from './image-carousel-edit-popover'; import { ImageCarouselImage } from './image-carousel-image'; import style from './image-carousel.module.scss'; @@ -48,176 +50,256 @@ export const ImageCarousel = ({ images, readonly, indexKey, onChange }: ImageCar const sliceStartIndex = page * numImages; const sliceEndIndex = Math.min(sliceStartIndex + numImages, images.length); + const stageRef = useRef(null); + const [zoomSlot, setZoomSlot] = useState(null); + const [isFullscreen, setIsFullscreen] = useState(false); + // Resolved in an effect, not during render: this page is statically + // exported, so `document` does not exist on the first pass and reading it + // inline would desync hydration. + const [canFullscreen, setCanFullscreen] = useState(false); + + useEffect(() => { + setCanFullscreen(document.fullscreenEnabled); + + const onChange = () => setIsFullscreen(document.fullscreenElement === stageRef.current); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + const toggleFullscreen = useCallback(() => { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => { + /* already exited, or the browser refused */ + }); + } else { + stageRef.current?.requestFullscreen().catch(() => { + /* refused, e.g. not from a user gesture */ + }); + } + }, []); + + // The toolbar stays in fullscreen so images remain switchable there; it + // just floats over the picture instead of sitting under it. + const chrome = useMemo(() => ({ zoomSlot, overlay: isFullscreen }), [zoomSlot, isFullscreen]); + + // Unchanged from when this governed the whole rail: a single image needs no + // thumbnails, but edit mode must keep rendering so `Add image` stays + // reachable. Do not narrow it. + const showThumbnails = !readonly || images.length > 1; + const showToolbar = selectedImage !== undefined || !readonly; + return ( -
-
- {selectedImage ? ( -
- -
- ) : ( - - )} - {(!readonly || images.length > 1) && ( - // Centred as one group: the arrows used to be pinned to the far - // edges of the page, a long way from the thumbnails they page. - // Wraps so the group never outgrows a narrow viewport. - // Sits inside the stage card so it reads as the image's controls - // rather than as a detached row floating below it. -
- {images.length >= 2 && ( - - )} -
- {sliceStartIndex > 0 && ( -
- -
+ +
+
+ {selectedImage ? ( +
+ +
+ ) : ( + + )} + {showToolbar && ( + // Zoom docked left, thumbnails centred, fullscreen right. + // Sits inside the stage card so it reads as the image's + // controls rather than as a detached row below it. +
{ - const index = sliceStartIndex + actualIndex; - return ( -
- {!readonly && ( -
+ data-overlay={isFullscreen || undefined} + > +
+ +
+ {showThumbnails && ( + <> + {images.length >= 2 && ( + + )} +
+ {sliceStartIndex > 0 && ( +
+ +
+ )} + {images.slice(sliceStartIndex, sliceEndIndex).map((image, actualIndex) => { + const index = sliceStartIndex + actualIndex; + return ( +
+ {!readonly && ( +
+ { + const newImages = [...images]; + newImages[index] = editedImage; + if (onChange) { + onChange(newImages); + } + }} + > + + + +
+ )} +
{ + setImageIndex(index); + }} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {image.caption +
+ {!readonly && ( +
+ + + +
+ )} +
+ ); + })} + {sliceEndIndex < images.length && ( +
+ +
+ )} + {!readonly && ( { + onChange={(newImage) => { const newImages = [...images]; - newImages[index] = editedImage; + newImages.push(newImage); if (onChange) { onChange(newImages); } }} > - + - -
- )} -
{ - setImageIndex(index); - }} - > - {/* eslint-disable-next-line @next/next/no-img-element */} - {image.caption
- {!readonly && ( -
- - - -
+ {images.length >= 2 && ( + )} -
- ); - })} - {sliceEndIndex < images.length && ( -
- -
- )} - {!readonly && ( - { - const newImages = [...images]; - newImages.push(newImage); - if (onChange) { - onChange(newImages); - } - }} - > - - - )} + {images.length >= 2 && ( + + {imageIndex + 1} / {images.length} + + )} + + )} +
+ +
+ {selectedImage && canFullscreen && ( + + )} +
- {images.length >= 2 && ( - - )} - {images.length >= 2 && ( - - {imageIndex + 1} / {images.length} - - )} -
- )} + )} +
-
+
); }; From 7d91086c765ac58ceaacb2c8988a1858122b5a43 Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 18:11:28 -0500 Subject: [PATCH 21/38] Stop the training details table drawing a second card inside the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The
and the MetadataTable inside it carried identical chrome — same 12px radius, same 0.8px border, same surface background — so expanding it revealed a second rounded box sitting a hairline inside the first. MetadataTable now takes `flush` to drop its card chrome, and the training disclosure passes it, since the
around it is already the card. The sidebar spec cards are unaffected and keep theirs: that is the caller that genuinely needs the table to be its own card. The summary/content divider and the row separators are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/model-page/metadata-table.tsx | 22 +++++++++++++++++-- .../model-page/training-details.tsx | 8 ++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/elements/components/model-page/metadata-table.tsx b/src/elements/components/model-page/metadata-table.tsx index 78d09059..e9040693 100644 --- a/src/elements/components/model-page/metadata-table.tsx +++ b/src/elements/components/model-page/metadata-table.tsx @@ -15,7 +15,20 @@ export type MetadataRow = false | null | undefined | readonly [string, ReactNode * Grouped by kind, because a flat list gave nine training fields the same * weight as the license. */ -export function MetadataTable({ title, rows }: { title?: string; rows: MetadataRow[] }) { +export function MetadataTable({ + title, + rows, + flush = false, +}: { + title?: string; + rows: MetadataRow[]; + /** + * Drop the card chrome. Set this when the table is already inside a card — + * otherwise its border and radius land on top of the parent's and you get a + * second rounded box nested a hairline inside the first. + */ + flush?: boolean; +}) { const filteredRows = rows.filter(isTrue); if (filteredRows.length === 0) return null; @@ -24,7 +37,12 @@ export function MetadataTable({ title, rows }: { title?: string; rows: MetadataR {title && (

{title}

)} -
+
{filteredRows.map((row, i) => { const [label, value] = row; return ( diff --git a/src/elements/components/model-page/training-details.tsx b/src/elements/components/model-page/training-details.tsx index f92b5a68..21201c4a 100644 --- a/src/elements/components/model-page/training-details.tsx +++ b/src/elements/components/model-page/training-details.tsx @@ -22,8 +22,14 @@ export function TrainingDetails({ rows, editMode }: TrainingDetailsProps) { Training details ({filled}) + {/* `flush`: the
above is already the card. Without it the + table draws its own border and radius a hairline inside the + parent's, which reads as a doubled rounded box on expand. */}
- +
); From d7cd6ba136b8358490c38f00f953091abbe918ca Mon Sep 17 00:00:00 2001 From: Joey Ballentine Date: Wed, 29 Jul 2026 18:38:37 -0500 Subject: [PATCH 22/38] Give form controls a theme instead of the operating system's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailwind's Preflight is disabled here, so