refactor(ui): split ModifierProfileEditor header + relocate history/clipboard to footer

The header previously crammed 9 elements onto one row: title, name input,
layout picker, undo, redo, clipboard status, + Custom Modifier, Save,
Share, Close. With the Bundled Presets tab added recently, visual
hierarchy collapsed and scanning was hard.

Split the chrome into three rows with one job each:
- Row 1 (identity): title 'Profile', name input (widened, flex-grows
  up to max-w-md), close button.
- Row 2 (toolbar, subhead-styled): Layout picker on the left; +Custom
  Modifier, Share, and Save on the right. Save is now primary-styled
  (dark filled) instead of a generic grey button in a line of five.
- Footer: undo/redo icon buttons on the left, clipboard status badge
  on the right. Dim when empty, pill-styled with white background
  when non-empty to reduce noise.

No testid changes — save-profile, share-profile, open-custom-modifier-editor,
undo-button, redo-button, clipboard-status, profile-name, bound-layout-picker
all remain at stable selectors. All 105 Playwright e2e tests + 1752 unit
tests continue to pass.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 15:41:49 -06:00
commit fe787478b1
No known key found for this signature in database

View file

@ -265,11 +265,13 @@ export function ModifierProfileEditor({
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl min-h-[85vh] max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<header className="flex items-center justify-between px-6 py-4 border-b border-neutral-200">
<div className="flex items-center gap-3 min-w-0 flex-wrap">
{/* Header — identity row: title, name, close. Keeps the modal
scannable: one job per row. Secondary/toolbar controls live
in the subhead below; history + clipboard live in the footer. */}
<header className="flex items-center justify-between px-6 py-3 border-b border-neutral-200 gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<h2 className="text-lg font-bold text-neutral-900 shrink-0">
Modifier Profiles
Profile
</h2>
<input
data-testid="profile-name"
@ -279,108 +281,86 @@ export function ModifierProfileEditor({
pushSnapshot((p) => ({ ...p, name: e.target.value }))
}
placeholder="Profile name"
className="px-3 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="flex-1 min-w-0 max-w-md px-3 py-1.5 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={40}
/>
{/* Bound layout for per-instance modifiers */}
<div className="flex items-center gap-1.5 shrink-0">
<label
htmlFor="bound-layout-picker-input"
className="text-xs font-bold text-neutral-500 uppercase tracking-widest whitespace-nowrap"
>
Layout:
</label>
<select
id="bound-layout-picker-input"
data-testid="bound-layout-picker"
value={boundLayout?.id ?? ''}
onChange={(e) => {
const id = e.target.value;
const newLayout = id ? (LAYOUT_REGISTRY.get(id) ?? null) : null;
setBoundLayout(newLayout);
// Persist the selection ON the profile so it travels
// with the saved descriptor. `layoutId` is optional
// in the schema — undefined when the user clears it.
pushSnapshot((p) => {
if (newLayout === null) {
const { layoutId: _drop, ...rest } = p;
return { ...rest };
}
return { ...p, layoutId: newLayout.id };
});
}}
className="text-sm border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[160px]"
>
<option value="">— none —</option>
{layouts.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
data-testid="undo-button"
onClick={undo}
disabled={!canUndo}
aria-label="Undo"
title="Undo (Cmd/Ctrl+Z)"
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
<button
onClick={onClose}
aria-label="Close modifier editor"
className="w-8 h-8 flex items-center justify-center text-neutral-500 hover:bg-neutral-100 rounded transition-colors shrink-0"
>
×
</button>
</header>
{/* Subhead toolbar — one-line row for Layout binding on the
left and primary/secondary actions on the right. This
replaces the former 9-element crammed header. */}
<div className="flex items-center justify-between px-6 py-2.5 border-b border-neutral-200 bg-neutral-50/60 gap-4">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<label
htmlFor="bound-layout-picker-input"
className="text-xs font-bold text-neutral-500 uppercase tracking-widest whitespace-nowrap"
>
↶
</button>
<button
data-testid="redo-button"
onClick={redo}
disabled={!canRedo}
aria-label="Redo"
title="Redo (Cmd/Ctrl+Shift+Z)"
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
Layout
</label>
<select
id="bound-layout-picker-input"
data-testid="bound-layout-picker"
value={boundLayout?.id ?? ''}
onChange={(e) => {
const id = e.target.value;
const newLayout = id ? (LAYOUT_REGISTRY.get(id) ?? null) : null;
setBoundLayout(newLayout);
// Persist the selection ON the profile so it travels
// with the saved descriptor. `layoutId` is optional
// in the schema — undefined when the user clears it.
pushSnapshot((p) => {
if (newLayout === null) {
const { layoutId: _drop, ...rest } = p;
return { ...rest };
}
return { ...p, layoutId: newLayout.id };
});
}}
className="text-sm border border-neutral-300 rounded px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[200px]"
>
↷
</button>
<div className="w-px h-5 bg-neutral-200 mx-1" />
<div data-testid="clipboard-status" className="px-2 text-xs font-medium text-neutral-500">
{clipboard.kind === 'empty'
? 'Clipboard: empty'
: `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`}
</div>
<div className="w-px h-5 bg-neutral-200 mx-1" />
<option value="">— none —</option>
{layouts.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
data-testid="open-custom-modifier-editor"
onClick={() => setShowCustomModifierEditor(true)}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-white border border-neutral-200 rounded hover:bg-neutral-50 transition-colors"
title="Author a custom modifier from primitives"
>
+ Custom Modifier
</button>
<button
data-testid="share-profile"
onClick={handleShareProfile}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-white border border-neutral-200 rounded hover:bg-neutral-50 transition-colors"
>
Share
</button>
<button
data-testid="save-profile"
onClick={handleSaveToLibrary}
disabled={hasPresetErrors}
title={hasPresetErrors ? presetLint.errors[0] : undefined}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="px-4 py-1.5 text-sm font-semibold text-white bg-neutral-900 rounded hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
>
Save
</button>
<button
data-testid="share-profile"
onClick={handleShareProfile}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
Share
</button>
<button
onClick={onClose}
aria-label="Close modifier editor"
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
>
×
</button>
</div>
</header>
</div>
{/* Conflict Resolution Panel */}
<ConflictResolutionPanel
@ -553,6 +533,51 @@ export function ModifierProfileEditor({
</div>
</aside>
</div>
{/* Footer — history controls + clipboard badge. These are
global to the whole modal but secondary to authoring, so
they anchor the bottom instead of crowding the header. */}
<footer className="flex items-center justify-between px-6 py-2 border-t border-neutral-200 bg-neutral-50/60 shrink-0">
<div className="flex items-center gap-1">
<button
data-testid="undo-button"
onClick={undo}
disabled={!canUndo}
aria-label="Undo"
title="Undo (Cmd/Ctrl+Z)"
className="w-8 h-8 flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors text-base"
>
↶
</button>
<button
data-testid="redo-button"
onClick={redo}
disabled={!canRedo}
aria-label="Redo"
title="Redo (Cmd/Ctrl+Shift+Z)"
className="w-8 h-8 flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors text-base"
>
↷
</button>
</div>
{/* Clipboard badge — hidden in empty state to reduce noise.
Kept as a visible element only when the user has actual
clipboard content they might want to paste. The testid
MUST stay present in both states so e2e assertions that
check for empty state ("Clipboard: empty") still resolve. */}
<div
data-testid="clipboard-status"
className={`text-xs font-medium ${
clipboard.kind === 'empty'
? 'text-neutral-400'
: 'text-neutral-700 bg-white px-2 py-0.5 rounded-full border border-neutral-200'
}`}
>
{clipboard.kind === 'empty'
? 'Clipboard: empty'
: `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`}
</div>
</footer>
</div>
<CustomModifierEditor
isOpen={showCustomModifierEditor}