Skip to content

feat(layout-library): header thumbnail quick-switch for layouts#1941

Merged
andymai merged 3 commits into
mainfrom
feat/layout-quick-switch
May 30, 2026
Merged

feat(layout-library): header thumbnail quick-switch for layouts#1941
andymai merged 3 commits into
mainfrom
feat/layout-quick-switch

Conversation

@andymai
Copy link
Copy Markdown
Owner

@andymai andymai commented May 30, 2026

What & why

Switching layouts was a modal round-trip. This adds a LayoutQuickSwitch to the header so it's a one-click, thumbnail-led action. Production data shows most users keep 1–2 layouts (82% ≤2), so recognition-by-shape in the header fits how layouts are actually used — and you flagged early that the thumbnail must stay.

This is stage 3 of 3:

  1. Tags foundationfeat(bin-designer): add synced organization tags to saved designs #1936
  2. Designs-manager rebuildfeat(bin-designer): organize saved designs with tags, filtering, and bulk actions #1939
  3. Header layout quick-switchthis PR

Changes

  • Header trigger = the active layout's thumbnail + a chevron (replaces the old text "Layouts" button).
  • Dropdown lists every layout (preview + name, ✓ on the active one) and switches on click; plus New layout and Manage layouts.
  • Manage opens the existing layout manager modal (now reached via the switch rather than a standalone button).
  • Inline header rename is preserved — the editable layout-name button is untouched, so quick rename still works.
  • Removes the now-orphaned header.layouts / header.openLayoutManager strings.

Scope notes

  • The layout manager modal keeps its full management UI (rename/duplicate/delete/share/import/export). Switching from it still works as a secondary path — no regression. I deliberately did not strip its grid/sort, since power users with many layouts benefit from it there.
  • A mobile full-width sheet variant is a possible follow-up; on small screens the dropdown still works.

Verification

  • Unit: LayoutQuickSwitch (6 tests — render/open/switch/active-noop/new/manage) + updated Header tests (27).
  • i18n: 2 keys across all 9 locales; check:i18n (keys/interpolation/values/unused) green.
  • Playwright visual check: header trigger + dropdown render correctly; the dropdown lists layouts with the active ✓, New Layout, and Manage layouts, styled consistently with the app's other dropdowns.
  • typecheck ✅ · lint ✅ · design-system ✅ · module-boundaries ✅

Adds a LayoutQuickSwitch control to the header so switching layouts is a
one-click, thumbnail-led action instead of a modal round-trip. Production data
shows most users keep 1–2 layouts (82% ≤2), so recognition-by-shape in the
header fits how layouts are actually used.

- Trigger shows the active layout's thumbnail + a chevron; the dropdown lists
  every layout (preview + name, ✓ on active) and switches on click
- "New layout" and "Manage layouts" live in the dropdown; Manage opens the
  existing layout manager (now reached via the switch, not a standalone button)
- Inline layout-name rename in the header is preserved unchanged
- Drops the now-orphaned header.layouts / header.openLayoutManager strings

The layout manager modal keeps its full management UI; switching from it still
works as a secondary path. i18n added across all 9 locales.
Copilot AI review requested due to automatic review settings May 30, 2026 00:28
@vercel
Copy link
Copy Markdown

vercel Bot commented May 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gridfinity-layout-tool Ready Ready Preview, Comment May 30, 2026 1:17am

Request Review

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 30, 2026

Greptile Summary

Replaces the old "Layouts" text button in the header with LayoutQuickSwitch — a thumbnail-led dropdown that lets users switch layouts in one click, create a new layout, or reach the full layout manager modal.

  • New LayoutQuickSwitch component renders the active layout's thumbnail + chevron as the trigger; the dropdown lists all library entries (with ✓ on the active one), a "New layout" action, and a "Manage layouts" action that opens the existing modal.
  • layout-library public barrel now exports LayoutQuickSwitch, and Header.tsx imports through that barrel — resolving the isolation-boundary issue from the prior review round.
  • i18n replaces the two orphaned header.layouts / header.openLayoutManager keys with header.switchLayout (with {name} interpolation) and header.manageLayouts, updated across all nine locales.

Confidence Score: 5/5

Safe to merge — functional logic, error handling, and module boundaries are all correct.

The core switching logic delegates errors to useLayoutSwitcher which already shows toasts, so no silent failures. The barrel export and import wiring are clean. The only open items are accessibility nits (aria-current semantics, unlabelled menu role, focus-on-Escape) that don't affect runtime behaviour.

The three accessibility comments are all in LayoutQuickSwitch.tsx; no other files need special attention.

Important Files Changed

Filename Overview
src/features/layout-library/components/LayoutQuickSwitch/LayoutQuickSwitch.tsx New component implementing the header thumbnail quick-switch dropdown; functionally sound with correct error handling delegated to useLayoutSwitcher, but has three accessibility gaps (aria-current misuse, unlabelled menu, no focus restoration on Escape).
src/features/layout-library/components/LayoutQuickSwitch/LayoutQuickSwitch.test.tsx Six focused tests covering render, open, switch, active-noop, new layout, and shared-preview fallback — good coverage for the new component.
src/shell/Header/Header.tsx Old layout-manager button cleanly replaced with LayoutQuickSwitch; import uses the public barrel as required.
src/shell/Header/Header.test.tsx LayoutQuickSwitch mocked at the sub-module path; the mock propagates correctly through the barrel re-export so Header tests remain isolated.
src/features/layout-library/index.ts LayoutQuickSwitch added to the public barrel, completing the previous review's requested fix.
src/i18n/locales/en.ts Two old header keys removed, two new ones added (header.switchLayout with {name} interpolation, header.manageLayouts); all nine locales updated consistently.

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/features/layout-library/components/LayoutQuickSwitch/LayoutQuickSwitch.tsx:148-163
**`aria-current` is non-standard for menu selection state**

`aria-current="true"` on a `role="menuitem"` signals "the current item in a navigation set" (breadcrumbs, tabs) — not "the selected option in a choice group". Screen readers vary in how they announce this; many will say "current" rather than "selected" or "checked". The ARIA-compliant pattern for a single-select menu is `role="menuitemradio"` with `aria-checked="true"` on the active entry and `aria-checked="false"` on all others. Wrapping the list in a `role="group"` with an `aria-label` rounds this out.

### Issue 2 of 3
src/features/layout-library/components/LayoutQuickSwitch/LayoutQuickSwitch.tsx:140-143
**`role="menu"` has no accessible name**

The WAI-ARIA authoring practices require elements with `role="menu"` to have an accessible name so screen readers can announce what the menu is for. Currently it renders as just "menu" with no context. Consider adding `aria-label={t('header.manageLayouts')}` or a more descriptive label so the purpose of the popup is clear.

### Issue 3 of 3
src/features/layout-library/components/LayoutQuickSwitch/LayoutQuickSwitch.tsx:102-104
**Escape key doesn't restore focus to the trigger**

When a keyboard or screen-reader user opens the dropdown (via Space/Enter on the trigger), navigates into the list, and presses Escape to dismiss, focus is left stranded on whichever menu item had it rather than returned to the trigger button. Per the codebase's focus-management guideline ("restore focus after modal/dialog closes"), a `triggerRef` should be attached to the trigger button and called on Escape: `triggerRef.current?.focus()`.

Reviews (3): Last reviewed commit: "fix(layout-library): quick-switch trigge..." | Re-trigger Greptile

Comment thread src/shell/Header/Header.tsx Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a header-level LayoutQuickSwitch control to switch layouts via an always-visible thumbnail trigger, replacing the previous “Layouts” button and moving “Manage layouts” into the dropdown (opening the existing layout manager modal). This fits the layout-library UX by making layout switching a single interaction while preserving the existing inline layout-name rename behavior.

Changes:

  • Replaces the header “Layouts” button with a thumbnail-led LayoutQuickSwitch dropdown trigger.
  • Adds LayoutQuickSwitch component + unit tests; updates Header tests to mock the new control.
  • Updates i18n across locales (removes old header keys; adds new switch/manage strings) and allowlists the new component for design-system checks.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/shell/Header/Header.tsx Swaps the old layout-manager button for LayoutQuickSwitch and routes “Manage” to the existing modal.
src/shell/Header/Header.test.tsx Mocks LayoutQuickSwitch to keep header tests focused while still testing modal open/close wiring.
src/features/layout-library/components/LayoutQuickSwitch/LayoutQuickSwitch.tsx New dropdown component for thumbnail-led layout switching + manage/new actions.
src/features/layout-library/components/LayoutQuickSwitch/LayoutQuickSwitch.test.tsx Unit tests covering open/switch/active-noop/new/manage paths.
src/features/layout-library/components/LayoutQuickSwitch/index.ts Barrel export for the new component.
src/i18n/locales/en.ts Replaces old header strings with header.switchLayout / header.manageLayouts.
src/i18n/locales/en.json Replaces old header strings with header.switchLayout / header.manageLayouts.
src/i18n/locales/de.json Removes orphaned header keys; adds new switch/manage keys.
src/i18n/locales/es.json Removes orphaned header keys; adds new switch/manage keys.
src/i18n/locales/fr.json Removes orphaned header keys; adds new switch/manage keys.
src/i18n/locales/ja.json Removes orphaned header keys; adds new switch/manage keys.
src/i18n/locales/nb.json Removes orphaned header keys; adds new switch/manage keys.
src/i18n/locales/nl.json Removes orphaned header keys; adds new switch/manage keys.
src/i18n/locales/pt-BR.json Removes orphaned header keys; adds new switch/manage keys.
src/i18n/locales/sv.json Removes orphaned header keys; adds new switch/manage keys.
src/i18n/locales/uk.json Removes orphaned header keys; adds new switch/manage keys.
scripts/design-system-allowlist.txt Allowlists LayoutQuickSwitch.tsx for design-system checks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +20 to +26
const { activeLayoutId, library, switchLayout, createNewLayout } = useLayoutSwitcher();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

const hasLayouts = library.entries.length > 0;
const activeEntry = library.entries.find((e) => e.id === activeLayoutId) ?? library.entries[0];

Comment on lines +1 to +5
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLayoutSwitcher } from '@/shared/hooks';
import { LayoutThumbnail } from '@/shell/LayoutThumbnail';
import { layoutId } from '@/core/types';
import { useTranslation } from '@/i18n';
Comment on lines +66 to +71
aria-label={t('header.switchLayout', { name: activeEntry.name })}
>
<span className="flex h-6 w-8 items-center justify-center overflow-hidden rounded border border-stroke-subtle bg-surface">
<LayoutThumbnail
preview={activeEntry.preview}
size={30}
- export LayoutQuickSwitch from the feature barrel and import it there
  (Greptile); verified the barrel's static re-export of the lazily-loaded
  LayoutManagerModal does not change the main bundle
- mark the active layout with aria-current; the check icon is now decorative
  (aria-hidden) so screen readers announce the current item once
- extract Icon / Thumb / MenuAction helpers, collapsing ~50 lines of duplicated
  inline SVG and shared menu-item markup
@andymai
Copy link
Copy Markdown
Owner Author

andymai commented May 30, 2026

Thanks! Addressed in 4abfcd6:

  • Barrel import (P2): LayoutQuickSwitch is now exported from src/features/layout-library/index.ts and imported via the barrel in Header.tsx. I checked the bundle concern (Header lazy-loads LayoutManagerModal): the barrel's static re-export does not change the main chunk — LayoutManagerModal is already in main via a prefetch in usePrefetchChunks.ts, and main-bundle gzip is identical (78.9 kB) either way.

Plus a couple of self-review improvements in the same commit:

  • The active layout now uses aria-current (the check icon is aria-hidden), so screen readers announce the current item once instead of via a labelled SVG.
  • Extracted Icon/Thumb/MenuAction helpers, removing ~50 lines of duplicated inline SVG/menu markup.

Re-verified: typecheck ✅ · lint ✅ · 33 tests ✅ · Playwright visual check (dropdown renders identically post-refactor).

In shared-layout-preview / embed mode the active layout (SHARED_PREVIEW_ID) is
not a library entry, so the previous `find() ?? entries[0]` fallback mislabeled
the header trigger with the first library layout's name and thumbnail (Copilot).

The trigger now derives its name + preview from the live layout store
(computePreview) when there's no matching library entry, so it always reflects
what's on screen. The dropdown's switch list is unchanged. Added a test for the
shared-preview path.
@andymai
Copy link
Copy Markdown
Owner Author

andymai commented May 30, 2026

Good catch — fixed in ea219fe.

Shared-preview trigger: in shared-layout-preview / embed mode the active layout is SHARED_PREVIEW_ID (not a library entry), so the find() ?? entries[0] fallback mislabeled the trigger with the first library layout. The trigger now derives its name + thumbnail from the live layout store (computePreview) whenever the active layout isn't in the library, so it always reflects what's on screen. The dropdown's switch list is unchanged, and the leading divider is now suppressed when the library is empty. Added a unit test for the shared-preview path.

(The three comments were facets of this one issue — the imports concern is resolved since the fix adds useMemo / useLayoutStore / computePreview.)

Verified: typecheck ✅ · lint ✅ · 34 tests ✅ · module-boundaries ✅ · knip ✅

@andymai andymai merged commit 6ec4ec2 into main May 30, 2026
16 checks passed
@andymai andymai deleted the feat/layout-quick-switch branch May 30, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants