From 845da6b71d7b0b07134ba627d5ec16cef6acd3f7 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Sun, 31 May 2026 00:52:15 -0400 Subject: [PATCH 1/8] Improve mobile catalog UX --- v5/src/components/DetailShell.tsx | 1 + v5/src/components/GalleryShell.tsx | 159 ++++++------ v5/src/components/ToolCard.tsx | 1 + v5/src/styles/globals.css | 397 +++++++++++++++++++++++++++-- 4 files changed, 459 insertions(+), 99 deletions(-) diff --git a/v5/src/components/DetailShell.tsx b/v5/src/components/DetailShell.tsx index 11911bd..52618dc 100644 --- a/v5/src/components/DetailShell.tsx +++ b/v5/src/components/DetailShell.tsx @@ -77,6 +77,7 @@ export function DetailShell({ tool }: DetailShellProps) { sizes="(min-width: 980px) 45vw, 100vw" style={{ objectFit: "contain" }} priority + unoptimized /> diff --git a/v5/src/components/GalleryShell.tsx b/v5/src/components/GalleryShell.tsx index 1aba9ad..32db289 100644 --- a/v5/src/components/GalleryShell.tsx +++ b/v5/src/components/GalleryShell.tsx @@ -43,6 +43,7 @@ export function GalleryShell({ tools }: GalleryShellProps) { const [material, setMaterial] = useState(null); const [location, setLocation] = useState(null); const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); + const [filtersOpen, setFiltersOpen] = useState(false); const categories = useMemo( () => Array.from(new Set(tools.map((tool) => tool.category))).sort(), @@ -83,6 +84,15 @@ export function GalleryShell({ tools }: GalleryShellProps) { return matchSorter(faceted, normalizedQuery, { keys: SEARCH_KEYS.slice() }); }, [category, location, material, query, tools, training]); + const activeFilterCount = [category, training, material, location].filter(Boolean).length; + + function clearFilters() { + setCategory(null); + setTraining(null); + setMaterial(null); + setLocation(null); + } + return (
@@ -104,82 +114,16 @@ export function GalleryShell({ tools }: GalleryShellProps) { /> -
-
- {t("category")} -
- {categories.map((categoryName) => ( - - ))} -
-
- -
- {t("training")} -
- {TRAINING_LEVELS.map((level) => ( - - ))} -
-
- -
- {t("materials")} -
- {materials.map((materialName) => ( - - ))} -
-
- -
- {t("location")} -
- {locations.map((locationName) => ( - - ))} -
-
+
+
+ +
+
+ + + + + + + +
+ +
+ {activeFilterCount > 0 ? ( + + ) : null} + +
+
@@ -217,7 +222,7 @@ export function GalleryShell({ tools }: GalleryShellProps) { {tool.name} diff --git a/v5/src/components/ToolCard.tsx b/v5/src/components/ToolCard.tsx index dedafdd..b337bbd 100644 --- a/v5/src/components/ToolCard.tsx +++ b/v5/src/components/ToolCard.tsx @@ -19,6 +19,7 @@ export function ToolCard({ tool }: ToolCardProps) { fill sizes="(min-width: 1280px) 25vw, (min-width: 768px) 33vw, 50vw" style={{ objectFit: "contain" }} + unoptimized />
diff --git a/v5/src/styles/globals.css b/v5/src/styles/globals.css index 014c71f..f587f4d 100644 --- a/v5/src/styles/globals.css +++ b/v5/src/styles/globals.css @@ -152,6 +152,7 @@ img { gap: 8px; font-family: var(--font-display); font-weight: 700; + min-width: 0; } .brand-lockup span:last-child { @@ -435,6 +436,7 @@ p { .filter-console { display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 16px; padding: 16px; } @@ -442,6 +444,7 @@ p { .search-row { display: grid; grid-template-columns: 48px 1fr; + grid-column: 1 / -1; } .search-row span { @@ -476,18 +479,41 @@ p { } .filter-row { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - justify-content: space-between; - gap: 16px 32px; - flex-wrap: wrap; + gap: 12px 16px; + grid-column: 1; + grid-row: 2; } -.filter-group { +.filter-toolbar { display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; + align-items: end; + justify-content: flex-end; + gap: 10px; + grid-column: 2; + grid-row: 2; +} + +.filter-controls { + display: grid; + grid-template-columns: repeat(4, minmax(140px, 1fr)); + gap: 10px; + min-width: 0; +} + +.filter-actions { + display: flex; + align-items: end; + justify-content: flex-end; + gap: 10px; +} + +.filter-group { + display: grid; + gap: 6px; + min-width: 0; } .filter-group > span { @@ -496,6 +522,33 @@ p { font-size: 10px; } +.filter-select-group select { + width: 100%; + min-width: 0; + height: 36px; + border: 1px solid var(--outline); + background: var(--surface-container); + color: var(--on-surface); + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + outline: none; + padding: 0 32px 0 10px; + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, var(--on-surface-muted) 50%), + linear-gradient(135deg, var(--on-surface-muted) 50%, transparent 50%); + background-position: + calc(100% - 16px) 15px, + calc(100% - 11px) 15px; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} + +.filter-select-group select:focus { + border-color: var(--primary); +} + .chip-row, .detail-actions, .ppe-list { @@ -506,6 +559,8 @@ p { .chip, .view-toggle button, +.filter-toggle, +.filter-clear, .doc-chip { border: 1px solid var(--outline); background: transparent; @@ -531,8 +586,11 @@ p { .chip:hover, .view-toggle button:hover, +.filter-toggle:hover, +.filter-clear:hover, .doc-chip:hover, .chip-active, +.filter-toggle.is-active, .view-toggle .is-active { border-color: var(--primary); background: var(--primary); @@ -544,6 +602,27 @@ p { gap: 8px; } +.view-toggle button { + white-space: nowrap; +} + +.filter-toggle { + display: none; + height: 36px; + padding-inline: 10px; + font-family: var(--font-mono); + text-transform: uppercase; + white-space: nowrap; +} + +.filter-clear { + height: 36px; + padding-inline: 10px; + font-family: var(--font-mono); + text-transform: uppercase; + white-space: nowrap; +} + .tool-grid { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); @@ -986,6 +1065,7 @@ p { grid-template-rows: auto 1fr auto; width: min(360px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 128px)); + min-height: 360px; overflow: hidden; border: 1px solid var(--outline); background: var(--surface-container); @@ -1041,6 +1121,7 @@ p { } .chat-body { + min-height: 0; padding: 20px 18px 12px; overflow-y: auto; } @@ -1506,21 +1587,128 @@ p { } @media (max-width: 860px) { + :root { + --nav-height: 105px; + } + .top-nav { - align-items: flex-start; - flex-direction: column; - gap: 16px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px 12px; + padding: 12px 16px 10px; + } + + .brand-lockup { + overflow: hidden; + } + + .brand-lockup span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .brand-lockup span:last-child { + display: none; } .primary-nav { + grid-column: 1 / -1; + grid-row: 2; width: 100%; justify-content: space-between; + gap: 8px; + font-size: 11px; + } + + .primary-nav a { + flex: 1; + padding: 8px 4px; + text-align: center; + } + + .nav-actions { + grid-column: 2; + grid-row: 1; + justify-self: end; + } + + .status-strip { + position: static; + } + + .lang-select { + width: 32px; + height: 32px; + } + + .lang-select-glyph { + inset-inline-start: 50%; + transform: translateX(-50%); + font-size: 10px; + white-space: nowrap; + } + + .lang-select select { + width: 32px; + padding: 0; + color: transparent; + text-indent: -999px; + } + + .lang-select select:hover, + .lang-select select:focus-visible { + color: transparent; } .page-shell { padding: 24px 16px 96px; } + .gallery-header { + gap: 20px; + margin-bottom: 18px; + } + + .filter-console { + grid-template-columns: 1fr; + } + + .filter-toolbar { + grid-column: 1; + align-items: stretch; + justify-content: space-between; + } + + .filter-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 112px; + } + + .filter-row { + display: none; + grid-column: 1; + grid-row: auto; + grid-template-columns: 1fr; + padding-top: 2px; + } + + .filter-row.is-open { + display: grid; + } + + .filter-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-actions { + align-items: stretch; + justify-content: space-between; + } + .detail-hero, .unit-row { grid-template-columns: 1fr; @@ -1539,38 +1727,135 @@ p { } .chat-fab { - right: 16px; - bottom: 16px; + right: 14px; + bottom: 14px; + width: 48px; + height: 48px; + } + + .chat-scrim { + display: none; } .chat-sheet { - right: 16px; - bottom: 88px; + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + min-height: 0; + max-height: none; + border: 0; + border-radius: 0 !important; + box-shadow: none !important; + } + + .chat-header { + padding: calc(12px + env(safe-area-inset-top)) 14px 12px; + } + + .chat-body { + padding: 16px 14px 12px; + } + + .chat-composer { + padding: 10px 10px calc(10px + env(safe-area-inset-bottom)); } } @media (max-width: 560px) { + :root { + --nav-height: 104px; + } + .status-strip { padding-inline: 16px; } - .filter-row, - .filter-group, + h1 { + font-size: 36px; + } + + .title-row { + gap: 10px; + } + + .target-glyph { + width: 34px; + height: 34px; + font-size: 30px; + } + + .filter-console { + gap: 12px; + padding: 12px; + } + + .search-row { + grid-template-columns: 38px 1fr; + } + + .search-row input { + padding: 12px; + } + + .filter-controls { + grid-template-columns: 1fr; + gap: 8px; + } + + .filter-toolbar, .view-toggle { - align-items: stretch; width: 100%; } - .chip-row, + .filter-toggle { + flex: 1; + } + .view-toggle { flex: 1; } - .chip, + .filter-clear, .view-toggle button { flex: 1; } + .tool-grid { + gap: 10px; + } + + .tool-card { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + min-height: 118px; + } + + .tool-card-image { + aspect-ratio: auto; + height: 100%; + min-height: 118px; + padding: 10px; + } + + .tool-card-body { + justify-content: center; + gap: 8px; + padding: 14px; + } + + .tool-card-body h2 { + font-size: 14px; + line-height: 1.2; + } + + .tool-card-tag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .spec-table div { display: grid; } @@ -1579,6 +1864,14 @@ p { padding: 0 12px 12px; text-align: left; } + + .chat-msg { + max-width: 94%; + } + + .chat-suggestion { + padding: 12px 13px; + } } .loading-line { @@ -2376,20 +2669,59 @@ p { } @media (max-width: 560px) { + .tool-detail { + width: 100%; + margin: 14px auto 32px; + padding: 0 14px; + gap: 16px; + overflow-x: hidden; + } + + .td-breadcrumbs { + margin: 0; + font-size: 12px; + } + .td-hero, .td-panel, .td-safety { + min-width: 0; padding: 18px; } + .td-hero { + gap: 18px; + } + .td-hero-image { min-height: 220px; } + .td-hero-copy { + min-width: 0; + } + .td-hero-copy h1 { font-size: 26px; } + .td-hero-copy > p { + font-size: 15px; + overflow-wrap: anywhere; + } + + .td-chip-row { + gap: 8px; + } + + .td-chip { + max-width: 100%; + height: auto; + min-height: 34px; + padding: 8px 10px; + white-space: normal; + } + .td-actions { display: grid; } @@ -2406,6 +2738,22 @@ p { grid-column: 1 / -1; width: fit-content; } + + .td-kv-table th, + .td-kv-table td { + display: block; + width: 100%; + padding-inline: 0; + } + + .td-kv-table th { + padding-bottom: 2px; + border-bottom: none; + } + + .td-kv-table td { + padding-top: 0; + } } /* --------------------------------------------------------------------------- @@ -2432,9 +2780,14 @@ p { } @media (max-width: 860px) { - [dir="rtl"] .chat-fab, - [dir="rtl"] .chat-sheet { + [dir="rtl"] .chat-fab { right: auto; left: 16px; } + + [dir="rtl"] .chat-sheet { + inset: 0; + right: 0; + left: 0; + } } From da9b42207fac6c7a5ffd1f8fbabce7e7d9cb07aa Mon Sep 17 00:00:00 2001 From: Isaac S Date: Tue, 2 Jun 2026 00:12:51 -0400 Subject: [PATCH 2/8] Improve mobile catalog UX: responsive grid, multi-select filters, sortable table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grid: 2 cols mobile → 3/4/5 at sm/lg/xl, smaller cards - Category + Materials are multi-select chips; remove Training filter; Location stays single-select - Clear button inline on desktop - Mobile table renders as labeled cards instead of a broken stack - Sortable column headers (Tool/Category/Zone/Training) Co-Authored-By: Claude Opus 4.8 (1M context) --- v5/src/components/GalleryShell.tsx | 190 +++++++++++++++++++---------- v5/src/styles/globals.css | 120 ++++++++++++++---- 2 files changed, 221 insertions(+), 89 deletions(-) diff --git a/v5/src/components/GalleryShell.tsx b/v5/src/components/GalleryShell.tsx index 32db289..d89ef7f 100644 --- a/v5/src/components/GalleryShell.tsx +++ b/v5/src/components/GalleryShell.tsx @@ -12,12 +12,6 @@ interface GalleryShellProps { tools: MakerLabTool[]; } -const TRAINING_LEVELS = [ - { value: "Beginner", key: "trainingBeginner" }, - { value: "Intermediate", key: "trainingIntermediate" }, - { value: "Advanced", key: "trainingAdvanced" }, -] as const; - // Ranked, typo-tolerant search keys. match-sorter ranks earlier keys above // later ones when match quality ties, so key order doubles as relevance // weight: name first, then the structured metadata (category / tags / @@ -35,15 +29,29 @@ const SEARCH_KEYS: ReadonlyArray = [ "description", ]; +type SortKey = "name" | "category" | "zone" | "trainingLevel"; +type SortState = { key: SortKey; dir: "asc" | "desc" }; + +// Table columns, in render order. `key` drives sorting; `labelKey` is the i18n +// header string. +const TABLE_COLUMNS: ReadonlyArray<{ key: SortKey; labelKey: string }> = [ + { key: "name", labelKey: "columnTool" }, + { key: "category", labelKey: "columnCategory" }, + { key: "zone", labelKey: "columnZone" }, + { key: "trainingLevel", labelKey: "columnTraining" }, +]; + export function GalleryShell({ tools }: GalleryShellProps) { const t = useTranslations("gallery"); const [query, setQuery] = useState(""); - const [category, setCategory] = useState(null); - const [training, setTraining] = useState(null); - const [material, setMaterial] = useState(null); + // Category and materials are multi-select facets (OR within each facet); + // location stays single-select. + const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedMaterials, setSelectedMaterials] = useState([]); const [location, setLocation] = useState(null); const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); const [filtersOpen, setFiltersOpen] = useState(false); + const [sort, setSort] = useState(null); const categories = useMemo( () => Array.from(new Set(tools.map((tool) => tool.category))).sort(), @@ -62,37 +70,69 @@ export function GalleryShell({ tools }: GalleryShellProps) { ); const filteredTools = useMemo(() => { - // Apply facet filters first; each dimension is single-select and they - // combine with AND across dimensions. + // Apply facet filters first. Within a multi-select facet the values OR + // together; the facets then AND across dimensions. const faceted = tools.filter((tool) => { - const matchesCategory = !category || tool.category === category; - const matchesTraining = !training || tool.trainingLevel === training; - const matchesMaterial = !material || tool.materials.includes(material); + const matchesCategory = + selectedCategories.length === 0 || selectedCategories.includes(tool.category); + const matchesMaterial = + selectedMaterials.length === 0 || + selectedMaterials.some((value) => tool.materials.includes(value)); const matchesLocation = !location || tool.location === location; - return matchesCategory && matchesTraining && matchesMaterial && matchesLocation; + return matchesCategory && matchesMaterial && matchesLocation; }); const normalizedQuery = query.trim(); - // Empty query preserves the catalog's existing order (show all). - if (!normalizedQuery) { - return faceted; + // Empty query preserves the catalog's existing order; a query applies a + // fuzzy, ranked match across the weighted keys (typo tolerant). + const searched = normalizedQuery + ? matchSorter(faceted, normalizedQuery, { keys: SEARCH_KEYS.slice() }) + : faceted; + + // An explicit column sort overrides both catalog order and search ranking. + if (!sort) { + return searched; } - // Fuzzy, ranked match across the weighted keys (typo tolerant). - return matchSorter(faceted, normalizedQuery, { keys: SEARCH_KEYS.slice() }); - }, [category, location, material, query, tools, training]); + return [...searched].sort((a, b) => { + const left = String(a[sort.key] ?? "").toLowerCase(); + const right = String(b[sort.key] ?? "").toLowerCase(); + const comparison = left.localeCompare(right); + return sort.dir === "asc" ? comparison : -comparison; + }); + }, [location, query, selectedCategories, selectedMaterials, sort, tools]); + + const activeFilterCount = + selectedCategories.length + selectedMaterials.length + (location ? 1 : 0); - const activeFilterCount = [category, training, material, location].filter(Boolean).length; + function toggleCategory(value: string) { + setSelectedCategories((prev) => + prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value] + ); + } + + function toggleMaterial(value: string) { + setSelectedMaterials((prev) => + prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value] + ); + } function clearFilters() { - setCategory(null); - setTraining(null); - setMaterial(null); + setSelectedCategories([]); + setSelectedMaterials([]); setLocation(null); } + function toggleSort(key: SortKey) { + setSort((prev) => + prev && prev.key === key + ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } + : { key, dir: "asc" } + ); + } + return (
@@ -143,43 +183,47 @@ export function GalleryShell({ tools }: GalleryShellProps) {
-
+ @@ -212,11 +255,24 @@ export function GalleryShell({ tools }: GalleryShellProps) { filteredTools.map((tool) => ) ) : ( <> -
-
+
{t("materials")} -
- {materials.map((materialName) => { - const active = selectedMaterials.includes(materialName); - return ( - - ); - })} -
+ + + {materialsOpen ? ( +
+ {materialGroups.map((group) => { + const allSelected = group.items.every((value) => selectedMaterials.includes(value)); + return ( +
+ +
+ {group.items.map((materialName) => { + const active = selectedMaterials.includes(materialName); + return ( + + ); + })} +
+
+ ); + })} +
+ ) : null}