[codex] Improve mobile catalog UX#27
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…table table - 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) <noreply@anthropic.com>
The dense table degrades to an oversized card stack on phones. Below 860px the gallery now always renders the grid and the Grid/Table switcher is hidden. The table (with sortable headers) is unchanged on desktop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Materials now collapses behind an "All materials / N selected" toggle that opens a panel grouping the chips by type (Plastics & Polymers, Wood, Metal, Other). Clicking a type heading selects or clears the whole group; individual chips still multi-select. Taxonomy is defined in MATERIAL_GROUPS since the catalog has no material-type field; unknown values fall into "Other". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Category now matches Materials/Location: a collapsible "All categories / N selected" dropdown instead of an always-expanded chip wall. All three filters are now consistent dropdowns on desktop, and on mobile they sit behind the Filters toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Filters now sit side by side and wrap only when they don't fit; open dropdown panels float (absolute) so opening one doesn't shift the row. On mobile they keep stacking with in-flow panels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Category and Materials dropdowns now open a vertical menu of option rows with checkbox affordances (filled when selected), hover highlight, scrolling, and grouped section headers for materials — instead of a chip cloud. Added click-outside and Escape to close, like a native <select>. Multi-select is preserved; no UI library added (would clash with the custom brutalist styling). Removed the now-unused chip CSS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… table - Move Clear into the same toolbar as the Grid/Table switcher so they sit on one line (centered together); remove the old standalone Clear + dead CSS. - Drop the dropdown toggle's max-width so Category/Materials/Location are all equal full width (they were capped while Location filled 100%). - Restore the table view on mobile: instead of forcing the grid, keep the desktop columnar table and let it scroll horizontally; un-hide the toggle. - Mobile toolbar wraps: Filters on its own row, Clear + Grid/Table below. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Reworks the gallery and chrome for mobile: facet chips become compact dropdowns hidden behind a Filters toggle on phones, the top nav collapses into brand + tab nav + compact utility buttons, tool detail and chat surfaces use phone real estate more efficiently, and local images in cards/table/hero are served unoptimized to avoid Next image optimizer failures.
Changes:
GalleryShelladopts multi-select Category and Materials dropdowns, a Location<select>, a mobile-only Filters toggle with active-count, outside-click/Escape close behavior, and sortable table column headers.- Heavy
globals.cssrewrite for the filter console, two-column mobile grid, scrollable table, full-screen mobile chat sheet, and tightened mobile nav/status strip. - Local images in
ToolCard/DetailShell/ gallery table thumb passunoptimizedto bypass the Next image optimizer.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| v5/src/components/GalleryShell.tsx | New dropdown facets, multi-select state, sort state, outside-click handling, table sort headers |
| v5/src/components/ToolCard.tsx | Adds unoptimized to the card image |
| v5/src/components/DetailShell.tsx | Adds unoptimized to the hero image |
| v5/src/styles/globals.css | Adds filter-dropdown styles, sortable header styles, mobile grid/nav/chat/detail refinements |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Filters{activeFilterCount > 0 ? ` (${activeFilterCount})` : ""} | ||
| </button> | ||
|
|
||
| <div className="filter-group"> | ||
| <span>{t("location")}</span> | ||
| <div className="chip-row"> | ||
| {locations.map((locationName) => ( | ||
| <button | ||
| className={location === locationName ? "chip chip-active" : "chip"} | ||
| key={locationName} | ||
| type="button" | ||
| aria-pressed={location === locationName} | ||
| onClick={() => | ||
| setLocation((selected) => (selected === locationName ? null : locationName)) | ||
| } | ||
| > | ||
| {locationName} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| {activeFilterCount > 0 ? ( | ||
| <button className="filter-clear" type="button" onClick={clearFilters}> | ||
| Clear {activeFilterCount} | ||
| </button> |
| <span> | ||
| {selectedCategories.length > 0 ? `${selectedCategories.length} selected` : "All categories"} | ||
| </span> | ||
| <span className="filter-dropdown-caret" aria-hidden="true"> | ||
| {categoryOpen ? "▲" : "▼"} | ||
| </span> | ||
| </button> | ||
|
|
||
| {categoryOpen ? ( | ||
| <div className="filter-dropdown-panel" role="listbox" aria-multiselectable="true"> | ||
| {categories.map((categoryName) => { | ||
| const active = selectedCategories.includes(categoryName); | ||
| return ( | ||
| <button | ||
| key={categoryName} | ||
| type="button" | ||
| role="option" | ||
| aria-selected={active} | ||
| className="filter-option" | ||
| onClick={() => toggleCategory(categoryName)} | ||
| > | ||
| <span className="filter-option-check" aria-hidden="true">{active ? "✓" : ""}</span> | ||
| <span>{categoryName}</span> | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
|
|
||
| <div className="filter-group filter-dropdown-group" role="group" aria-label={t("materials")} ref={materialsRef}> | ||
| <span>{t("materials")}</span> | ||
| <button | ||
| type="button" | ||
| className={ | ||
| materialsOpen || selectedMaterials.length > 0 | ||
| ? "filter-dropdown-toggle is-active" | ||
| : "filter-dropdown-toggle" | ||
| } | ||
| aria-expanded={materialsOpen} | ||
| onClick={() => setMaterialsOpen((open) => !open)} | ||
| > | ||
| <span> | ||
| {selectedMaterials.length > 0 ? `${selectedMaterials.length} selected` : "All materials"} | ||
| </span> | ||
| <span className="filter-dropdown-caret" aria-hidden="true"> | ||
| {materialsOpen ? "▲" : "▼"} | ||
| </span> | ||
| </button> | ||
|
|
||
| {materialsOpen ? ( | ||
| <div className="filter-dropdown-panel" role="listbox" aria-multiselectable="true"> | ||
| {materialGroups.map((group) => { | ||
| const allSelected = group.items.every((value) => selectedMaterials.includes(value)); | ||
| return ( | ||
| <div className="filter-menu-section" key={group.label}> | ||
| <button | ||
| type="button" | ||
| className="filter-menu-section-head" | ||
| aria-pressed={allSelected} | ||
| onClick={() => toggleMaterialGroup(group.items, allSelected)} | ||
| > | ||
| {group.label} | ||
| </button> | ||
| {group.items.map((materialName) => { | ||
| const active = selectedMaterials.includes(materialName); | ||
| return ( | ||
| <button | ||
| key={materialName} | ||
| type="button" | ||
| role="option" | ||
| aria-selected={active} | ||
| className="filter-option" | ||
| onClick={() => toggleMaterial(materialName)} | ||
| > | ||
| <span className="filter-option-check" aria-hidden="true">{active ? "✓" : ""}</span> | ||
| <span>{materialName}</span> | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
|
|
||
| <label className="filter-group filter-select-group"> | ||
| <span>{t("location")}</span> | ||
| <select value={location ?? ""} onChange={(event) => setLocation(event.target.value || null)}> | ||
| <option value="">All</option> | ||
| {locations.map((locationName) => ( | ||
| <option key={locationName} value={locationName}> | ||
| {locationName} | ||
| </option> | ||
| ))} | ||
| </select> |
| const MATERIAL_GROUPS: ReadonlyArray<{ label: string; values: string[] }> = [ | ||
| { | ||
| label: "Plastics & Polymers", | ||
| values: ["ABS", "Acrylic", "Composite", "Nylon", "PETG", "PLA", "Plastic", "Polycarbonate", "PVC", "Resin", "TPU", "Vinyl"], | ||
| }, | ||
| { label: "Wood", values: ["Hardwood", "Softwood", "Plywood", "MDF", "Veneer", "Laminate", "Wood"] }, | ||
| { label: "Metal", values: ["Aluminum", "Brass", "Copper", "Steel"] }, | ||
| { label: "Other", values: ["Cardboard", "Ceramic", "Fabric", "Foam", "Glass", "Leather", "Paper", "Rubber"] }, | ||
| ]; | ||
|
|
||
| const OTHER_GROUP_LABEL = "Other"; |
| // Category and materials are multi-select facets (OR within each facet); | ||
| // location stays single-select. | ||
| const [selectedCategories, setSelectedCategories] = useState<string[]>([]); | ||
| const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]); | ||
| const [location, setLocation] = useState<string | null>(null); |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1de6e4b075
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| aria-controls="gallery-filter-controls" | ||
| onClick={() => setFiltersOpen((open) => !open)} | ||
| > | ||
| Filters{activeFilterCount > 0 ? ` (${activeFilterCount})` : ""} |
There was a problem hiding this comment.
Localize the new filter labels
In non-English locales, the newly added filter toolbar now renders literal English strings such as Filters, and the same pattern is used for Clear, selected, All categories, All materials, and the material group headings. Since this component otherwise uses next-intl, these labels bypass the message files and leave a prominent part of the mobile filtering UI untranslated for every supported locale.
Useful? React with 👍 / 👎.
| return [...searched].sort((a, b) => { | ||
| const left = String(a[sort.key] ?? "").toLowerCase(); | ||
| const right = String(b[sort.key] ?? "").toLowerCase(); | ||
| const comparison = left.localeCompare(right); |
There was a problem hiding this comment.
Rank training levels explicitly when sorting
When users click the Training column, the generic string comparison sorts the ordinal training levels alphabetically, so ascending order becomes Advanced, Beginner, Intermediate instead of easiest-to-hardest. Since trainingLevel is a fixed enum, this makes the new sortable table misleading for users looking for tools by training requirement.
Useful? React with 👍 / 👎.
Summary
Filterstoggle./tool-imagesassets render reliably in cards, tables, and detail heroes.Why
Mobile users were losing too much viewport height to filters and chrome, and the chat/detail surfaces did not use the phone screen efficiently. During screenshot review, the desktop laser hero image also failed through the Next image optimizer at larger generated sizes.
Validation
npm run lintnpm run typechecknpm run build.context/screenshots/audit/covering gallery, filters open/closed, Form 4 detail, Trotec laser detail, Projects/About samples, and chat open/sent states.Notes
Chat UI behavior was verified locally, but live assistant responses require
ANTHROPIC_API_KEY; without it the local API returns the expected error state.