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:
parent
699c288a98
commit
fe787478b1
1 changed files with 110 additions and 85 deletions
|
|
@ -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"
|
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">
|
<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 — identity row: title, name, close. Keeps the modal
|
||||||
<header className="flex items-center justify-between px-6 py-4 border-b border-neutral-200">
|
scannable: one job per row. Secondary/toolbar controls live
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
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">
|
<h2 className="text-lg font-bold text-neutral-900 shrink-0">
|
||||||
Modifier Profiles
|
Profile
|
||||||
</h2>
|
</h2>
|
||||||
<input
|
<input
|
||||||
data-testid="profile-name"
|
data-testid="profile-name"
|
||||||
|
|
@ -279,108 +281,86 @@ export function ModifierProfileEditor({
|
||||||
pushSnapshot((p) => ({ ...p, name: e.target.value }))
|
pushSnapshot((p) => ({ ...p, name: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="Profile name"
|
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}
|
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>
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<button
|
onClick={onClose}
|
||||||
data-testid="undo-button"
|
aria-label="Close modifier editor"
|
||||||
onClick={undo}
|
className="w-8 h-8 flex items-center justify-center text-neutral-500 hover:bg-neutral-100 rounded transition-colors shrink-0"
|
||||||
disabled={!canUndo}
|
>
|
||||||
aria-label="Undo"
|
×
|
||||||
title="Undo (Cmd/Ctrl+Z)"
|
</button>
|
||||||
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
|
</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"
|
||||||
>
|
>
|
||||||
↶
|
Layout
|
||||||
</button>
|
</label>
|
||||||
<button
|
<select
|
||||||
data-testid="redo-button"
|
id="bound-layout-picker-input"
|
||||||
onClick={redo}
|
data-testid="bound-layout-picker"
|
||||||
disabled={!canRedo}
|
value={boundLayout?.id ?? ''}
|
||||||
aria-label="Redo"
|
onChange={(e) => {
|
||||||
title="Redo (Cmd/Ctrl+Shift+Z)"
|
const id = e.target.value;
|
||||||
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
|
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]"
|
||||||
>
|
>
|
||||||
↷
|
<option value="">— none —</option>
|
||||||
</button>
|
{layouts.map((l) => (
|
||||||
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
<option key={l.id} value={l.id}>
|
||||||
<div data-testid="clipboard-status" className="px-2 text-xs font-medium text-neutral-500">
|
{l.name}
|
||||||
{clipboard.kind === 'empty'
|
</option>
|
||||||
? 'Clipboard: empty'
|
))}
|
||||||
: `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`}
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
data-testid="open-custom-modifier-editor"
|
data-testid="open-custom-modifier-editor"
|
||||||
onClick={() => setShowCustomModifierEditor(true)}
|
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"
|
title="Author a custom modifier from primitives"
|
||||||
>
|
>
|
||||||
+ Custom Modifier
|
+ Custom Modifier
|
||||||
</button>
|
</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
|
<button
|
||||||
data-testid="save-profile"
|
data-testid="save-profile"
|
||||||
onClick={handleSaveToLibrary}
|
onClick={handleSaveToLibrary}
|
||||||
disabled={hasPresetErrors}
|
disabled={hasPresetErrors}
|
||||||
title={hasPresetErrors ? presetLint.errors[0] : undefined}
|
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
|
Save
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
{/* Conflict Resolution Panel */}
|
{/* Conflict Resolution Panel */}
|
||||||
<ConflictResolutionPanel
|
<ConflictResolutionPanel
|
||||||
|
|
@ -553,6 +533,51 @@ export function ModifierProfileEditor({
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<CustomModifierEditor
|
<CustomModifierEditor
|
||||||
isOpen={showCustomModifierEditor}
|
isOpen={showCustomModifierEditor}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue