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"
|
||||
>
|
||||
<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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue