v7.16.0 — presets tab rearranged: management panel on top, full-width tree below

User request: drop the side-by-side split so the 'Load Preset' section
sits on top at full width with no wasted height (even when a preset is
selected) and the preset tree below uses the whole section width, so
preset names never clip. Buttons pack into one compact grid row on
desktop (2x3 for a user folder; phones keep the old stacking); the tree
fills the tab body's leftover height on desktop (h-full flex chain,
420px cap only below lg); club-mode scope line + RANDOM/SEQUENCE pill
share one row. No logic changes. Worklog Task 48.
This commit is contained in:
zai-bot 2026-11-06 08:40:11 +00:00
parent 6dd7f7ead7
commit 062cd79952
4 changed files with 306 additions and 224 deletions

View file

@ -9,9 +9,14 @@
// nothing is sent to the server, so nobody can spam or vandalize what
// other visitors see.
//
// Left column: the tree (chevron expands, row selects, double-click runs,
// running preset flagged emerald, originals download from the row).
// Right column: context-sensitive management + the club-mode randomizer.
// v7.16 layout (user request): the two old side-by-side columns are gone.
// The context-sensitive management panel (selection header + action buttons
// + the club-mode randomizer) sits ON TOP at full width and its natural
// height -- one compact button grid row on desktop, never stretched, so no
// empty space is wasted below it. The folder/file tree (chevron expands,
// row selects, double-click runs, running preset flagged emerald, originals
// download from the row) fills the remaining tab height BELOW it at full
// width too -- full-width rows mean preset names never clip.
'use client'
@ -751,8 +756,11 @@ export function PresetsTab() {
// -----------------------------------------------------------------
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
// v7.16: h-full lets the tree fill the tab body's height on desktop
// (where the panel scrolls internally); below lg the chain is
// auto-height, so this stays natural and the page scrolls.
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex shrink-0 items-center justify-between gap-2">
<p className="text-xs text-zinc-500">
<span className="font-mono tabular-nums text-zinc-400">{presets.length}</span> presets ·{' '}
<span className="font-mono tabular-nums text-zinc-400">{folders.length}</span> folders
@ -760,256 +768,263 @@ export function PresetsTab() {
<p className="hidden text-[11px] text-zinc-600 sm:block">double-click a preset to run it</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
{/* LEFT -- folder/file tree */}
<div className="min-w-0 flex-1 rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-2 sm:w-[58%] sm:shrink-0">
<div
role="tree"
aria-label="Preset library"
className={cn(thinScrollbar, 'max-h-[420px] overflow-y-auto pr-1 lg:max-h-[480px]')}
>
{tree.map((node) => (
<TreeFolder key={node.path} node={node} />
))}
{presets.length === 0 && (
<p className="px-2 py-3 text-center text-[11px] italic text-zinc-600">Loading presets…</p>
{/* TOP -- management panel: full width, natural height (never
stretched), buttons packed into one compact grid row on desktop */}
<div className="shrink-0 rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3">
<div className="flex items-start gap-2">
{selectedPreset ? (
<FileText className="mt-0.5 size-4 shrink-0 text-emerald-300/80" aria-hidden />
) : (
<Folder className="mt-0.5 size-4 shrink-0 text-emerald-300/80" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p
className="truncate text-xs font-medium text-zinc-100"
title={selectedPreset ? selectedPreset.name : selectedFolder}
>
{selectedPreset ? selectedPreset.name : folderName}
</p>
<p
className="truncate font-mono text-[10px] text-zinc-500"
title={selectedPreset ? selectedPreset.folderPath : selectedFolder}
>
{selectedPreset ? selectedPreset.folderPath : selectedFolder}
</p>
{!selectedPreset && (
<p className="mt-0.5 font-mono text-[10px] tabular-nums text-zinc-600">
{plural(folderPresetCount, 'preset')} · {plural(childFolderCount, 'subfolder')}
</p>
)}
</div>
{(folderReadOnly || (selectedPreset && !presetOwn)) && (
<span
className="flex shrink-0 items-center gap-1 rounded-full border border-zinc-700/70 bg-zinc-900/60 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-zinc-500"
title="Read-only -- identical for every visitor. Copy it to 'user created' to remix."
>
<Lock className="size-2.5" aria-hidden /> read-only
</span>
)}
</div>
{/* RIGHT -- management panel */}
<div className="flex min-w-0 flex-1 flex-col rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3">
<div className="flex items-start gap-2">
{selectedPreset ? (
<FileText className="mt-0.5 size-4 shrink-0 text-emerald-300/80" aria-hidden />
) : (
<Folder className="mt-0.5 size-4 shrink-0 text-emerald-300/80" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p
className="truncate text-xs font-medium text-zinc-100"
title={selectedPreset ? selectedPreset.name : selectedFolder}
>
{selectedPreset ? selectedPreset.name : folderName}
</p>
<p
className="truncate font-mono text-[10px] text-zinc-500"
title={selectedPreset ? selectedPreset.folderPath : selectedFolder}
>
{selectedPreset ? selectedPreset.folderPath : selectedFolder}
</p>
{!selectedPreset && (
<p className="mt-0.5 font-mono text-[10px] tabular-nums text-zinc-600">
{plural(folderPresetCount, 'preset')} · {plural(childFolderCount, 'subfolder')}
</p>
)}
</div>
{(folderReadOnly || (selectedPreset && !presetOwn)) && (
<span
className="flex shrink-0 items-center gap-1 rounded-full border border-zinc-700/70 bg-zinc-900/60 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-zinc-500"
title="Read-only -- identical for every visitor. Copy it to 'user created' to remix."
>
<Lock className="size-2.5" aria-hidden /> read-only
</span>
)}
</div>
<div className="mt-3 space-y-2">
{selectedPreset ? (
<>
<div className="mt-3">
{selectedPreset ? (
presetOwn ? (
/* one row on desktop: Load (primary, wider) | Rename | Delete */
<div className="grid grid-cols-2 gap-2 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)]">
<Button
size="sm"
onClick={() => loadPreset(selectedPreset)}
className="h-9 w-full gap-1.5 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
className="col-span-2 h-9 gap-1.5 whitespace-nowrap bg-emerald-400 text-emerald-950 hover:bg-emerald-300 sm:col-span-1"
title="Run this preset on the engine and open it in the editor"
>
<Play className="size-3.5" /> Load Preset
</Button>
{presetOwn ? (
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openDialog('rename', selectedPreset.name)}
title="Rename this preset (within its folder)"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Pencil className="size-3.5" /> Rename
</Button>
<Button
size="sm"
variant="outline"
onClick={openPresetDelete}
title="Delete this preset from your browser library"
className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
>
<Trash2 className="size-3.5" /> Delete
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
onClick={() => copyToMyPresets(selectedPreset)}
title="Save an editable copy of this preset into your browser's 'user created' folder"
className="h-9 w-full gap-1.5 border-zinc-700"
>
<CopyPlus className="size-3.5" /> Copy to my presets
</Button>
)}
</>
) : folderReadOnly ? (
<>
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
disabled={busy || folderPresetCount === 0}
onClick={() => void exportZip()}
title="Download this folder (with subfolders) as a .zip preserving the tree -- built in your browser"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Download className="size-3.5" /> Export .zip
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => importInputRef.current?.click()}
title="Import a .zip of .ava presets -- they land in your browser's 'user created' folder"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Upload className="size-3.5" /> Import .zip
</Button>
</div>
</>
<Button
size="sm"
variant="outline"
onClick={() => openDialog('rename', selectedPreset.name)}
title="Rename this preset (within its folder)"
className="h-9 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Pencil className="size-3.5" /> Rename
</Button>
<Button
size="sm"
variant="outline"
onClick={openPresetDelete}
title="Delete this preset from your browser library"
className="h-9 gap-1.5 whitespace-nowrap border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
>
<Trash2 className="size-3.5" /> Delete
</Button>
</div>
) : (
<>
/* embedded preset: Load | Copy to my presets */
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
size="sm"
variant="outline"
onClick={() => openDialog('createPreset', '')}
title={`Create a new preset from the starter template inside ${selectedFolder} (this browser)`}
className="h-9 w-full gap-1.5 border-zinc-700"
onClick={() => loadPreset(selectedPreset)}
className="h-9 gap-1.5 whitespace-nowrap bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
title="Run this preset on the engine and open it in the editor"
>
<FilePlus2 className="size-3.5" /> Create Preset
<Play className="size-3.5" /> Load Preset
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openDialog('newFolder', '')}
title={`Create a subfolder inside ${selectedFolder} (this browser)`}
className="h-9 w-full gap-1.5 border-zinc-700"
onClick={() => copyToMyPresets(selectedPreset)}
title="Save an editable copy of this preset into your browser's 'user created' folder"
className="h-9 gap-1.5 whitespace-nowrap border-zinc-700"
>
<FolderPlus className="size-3.5" /> New Subfolder
<CopyPlus className="size-3.5" /> Copy to my presets
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
disabled={folderProtected}
onClick={() => openDialog('rename', folderName)}
title={folderProtected ? '"user created" is the root of your library' : `Rename ${folderName} -- everything inside moves with it`}
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Pencil className="size-3.5" /> Rename
</Button>
<Button
size="sm"
variant="outline"
disabled={folderProtected}
onClick={openFolderDelete}
title={folderProtected ? '"user created" is the root of your library' : `Delete ${folderName} and everything inside it (this browser)`}
className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
>
<Trash2 className="size-3.5" /> Delete
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
disabled={busy || folderPresetCount === 0}
onClick={() => void exportZip()}
title="Download this folder (with subfolders) as a .zip preserving the tree -- built in your browser"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Download className="size-3.5" /> Export .zip
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => importInputRef.current?.click()}
title={`Import a .zip of .ava presets into ${selectedFolder} -- the tree inside the zip is preserved`}
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Upload className="size-3.5" /> Import .zip
</Button>
</div>
</>
)}
</div>
{/* preset mixing (club mode) -- Random or Sequence, one timer in the store */}
<div className="mt-4 border-t border-zinc-800/80 pt-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="flex items-center gap-2">
<Checkbox
id="ava-randomize"
checked={randomize}
onCheckedChange={(v) => useAvaStore.getState().setRandomize(v === true)}
aria-label="Random presets"
className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950"
/>
<Label htmlFor="ava-randomize" className="cursor-pointer text-xs text-zinc-300">
Random
</Label>
</span>
<span className="flex items-center gap-2">
<Checkbox
id="ava-sequence"
checked={sequenceMode}
onCheckedChange={(v) => useAvaStore.getState().setSequenceMode(v === true)}
aria-label="Sequence presets"
className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950"
/>
<Label htmlFor="ava-sequence" className="cursor-pointer text-xs text-zinc-300">
Sequence
</Label>
</span>
<span className="ml-auto flex items-center gap-1.5">
<Input
value={delayText}
onChange={(e) => onDelayChange(e.target.value)}
onBlur={onDelayBlur}
inputMode="numeric"
aria-label="Random switch delay in seconds"
className="h-8 w-16 border-zinc-700 bg-zinc-900 px-2 text-center font-mono text-xs tabular-nums"
/>
<span className="text-[11px] text-zinc-500">sec</span>
</span>
</div>
)
) : folderReadOnly ? (
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
disabled={busy || folderPresetCount === 0}
onClick={() => void exportZip()}
title="Download this folder (with subfolders) as a .zip preserving the tree -- built in your browser"
className="h-9 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Download className="size-3.5" /> Export .zip
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => importInputRef.current?.click()}
title="Import a .zip of .ava presets -- they land in your browser's 'user created' folder"
className="h-9 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Upload className="size-3.5" /> Import .zip
</Button>
</div>
{delayError && (
<p className="mt-1.5 text-[11px] text-rose-400">Delay must be between 3 and 60 seconds.</p>
)}
<p className="mt-2 text-[11px] leading-relaxed text-zinc-500">
) : (
/* own folder: 3-wide grid on desktop -- [Create | Subfolder |
Rename] over [Delete | Export | Import] -- two compact rows */
<div className="grid grid-cols-2 gap-2 sm:grid-cols-[minmax(0,1.18fr)_minmax(0,1.18fr)_minmax(0,1fr)]">
<Button
size="sm"
variant="outline"
onClick={() => openDialog('createPreset', '')}
title={`Create a new preset from the starter template inside ${selectedFolder} (this browser)`}
className="col-span-2 h-9 gap-1.5 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700 sm:col-span-1"
>
<FilePlus2 className="size-3.5" /> Create Preset
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openDialog('newFolder', '')}
title={`Create a subfolder inside ${selectedFolder} (this browser)`}
className="col-span-2 h-9 gap-1.5 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700 sm:col-span-1"
>
<FolderPlus className="size-3.5" /> New Subfolder
</Button>
<Button
size="sm"
variant="outline"
disabled={folderProtected}
onClick={() => openDialog('rename', folderName)}
title={folderProtected ? '"user created" is the root of your library' : `Rename ${folderName} -- everything inside moves with it`}
className="h-9 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Pencil className="size-3.5" /> Rename
</Button>
<Button
size="sm"
variant="outline"
disabled={folderProtected}
onClick={openFolderDelete}
title={folderProtected ? '"user created" is the root of your library' : `Delete ${folderName} and everything inside it (this browser)`}
className="h-9 gap-1.5 whitespace-nowrap border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
>
<Trash2 className="size-3.5" /> Delete
</Button>
<Button
size="sm"
variant="outline"
disabled={busy || folderPresetCount === 0}
onClick={() => void exportZip()}
title="Download this folder (with subfolders) as a .zip preserving the tree -- built in your browser"
className="h-9 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Download className="size-3.5" /> Export .zip
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => importInputRef.current?.click()}
title={`Import a .zip of .ava presets into ${selectedFolder} -- the tree inside the zip is preserved`}
className="h-9 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Upload className="size-3.5" /> Import .zip
</Button>
</div>
)}
</div>
{/* preset mixing (club mode) -- Random or Sequence, one timer in the store */}
<div className="mt-4 border-t border-zinc-800/80 pt-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="flex items-center gap-2">
<Checkbox
id="ava-randomize"
checked={randomize}
onCheckedChange={(v) => useAvaStore.getState().setRandomize(v === true)}
aria-label="Random presets"
className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950"
/>
<Label htmlFor="ava-randomize" className="cursor-pointer text-xs text-zinc-300">
Random
</Label>
</span>
<span className="flex items-center gap-2">
<Checkbox
id="ava-sequence"
checked={sequenceMode}
onCheckedChange={(v) => useAvaStore.getState().setSequenceMode(v === true)}
aria-label="Sequence presets"
className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950"
/>
<Label htmlFor="ava-sequence" className="cursor-pointer text-xs text-zinc-300">
Sequence
</Label>
</span>
<span className="ml-auto flex items-center gap-1.5">
<Input
value={delayText}
onChange={(e) => onDelayChange(e.target.value)}
onBlur={onDelayBlur}
inputMode="numeric"
aria-label="Random switch delay in seconds"
className="h-8 w-16 border-zinc-700 bg-zinc-900 px-2 text-center font-mono text-xs tabular-nums"
/>
<span className="text-[11px] text-zinc-500">sec</span>
</span>
</div>
{delayError && (
<p className="mt-1.5 text-[11px] text-rose-400">Delay must be between 3 and 60 seconds.</p>
)}
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
<p className="min-w-0 flex-1 truncate text-[11px] text-zinc-500">
{sequenceMode ? 'Sequence from:' : 'Random from:'}{' '}
<span className="font-mono text-zinc-400">{scopeLabel}</span>
{scopeCount === 0 && <span className="ml-1 text-amber-300/80">-- no presets in this folder yet</span>}
</p>
{(randomize || sequenceMode) && (
<div className="mt-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 font-mono text-[10px] text-emerald-300">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" aria-hidden />
{sequenceMode ? 'SEQUENCE' : 'RANDOM'} · every {randomIntervalSec}s
</span>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 font-mono text-[10px] text-emerald-300">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" aria-hidden />
{sequenceMode ? 'SEQUENCE' : 'RANDOM'} · every {randomIntervalSec}s
</span>
)}
</div>
</div>
</div>
{/* BELOW -- the folder/file tree: full width, filling every bit of
height the tab has left (capped only when the page itself
scrolls, below lg) so long names never clip */}
<div className="flex min-h-[160px] flex-1 flex-col rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-2">
<div
role="tree"
aria-label="Preset library"
className={cn(thinScrollbar, 'max-h-[420px] min-h-0 flex-1 overflow-y-auto pr-1 lg:max-h-none')}
>
{tree.map((node) => (
<TreeFolder key={node.path} node={node} />
))}
{presets.length === 0 && (
<p className="px-2 py-3 text-center text-[11px] italic text-zinc-600">Loading presets…</p>
)}
</div>
</div>
{/* name dialogs */}
<NameDialog
open={dialog === 'createPreset'}

View file

@ -39,6 +39,20 @@ export interface ReleaseNote {
}
export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '7.16.0',
name: 'Presets tab, rearranged',
changes: [
{
kind: 'improved',
text: 'The Presets tab dropped its side-by-side split. The preset controls (Load Preset and friends) now run across the full top of the section at exactly the height they need -- the dead space that used to sit under them when a preset was selected is gone -- and the preset tree takes the whole width below, so long preset names stop getting cut off by the old narrow column.',
},
{
kind: 'improved',
text: 'On desktop the action buttons share one compact row (or two, for a folder), the tree grows to fill whatever height the panel has left, and the random/sequence status pill sits beside its scope line instead of below it.',
},
],
},
{
version: '7.15.2',
name: 'Facebook page + theme switch in Settings',

View file

@ -12,6 +12,32 @@
// warns in the console when the two drift apart.
//
// History:
// 7.16 — Presets tab rearranged (user request): "instead of having the
// two sections side by side ... the 'Load Preset' button section
// is on top with full width use of the preset section, the height
// set to where there is no empty space being wasted (even when a
// preset is selected). The preset tree should be placed below
// that with its width expanded to use the whole width of the
// Presets section ... so the preset names won't be clipped due to
// a restriction of horizontal space." The old 58/42 side-by-side
// split is gone: the management panel (selection header, action
// buttons, club-mode randomizer) sits ON TOP at the section's
// full width and its natural height -- never stretched, so the
// dead space that used to sit under the buttons when a preset was
// selected is gone. Its buttons pack into one compact grid row on
// desktop (Load Preset | Rename | Delete for an own preset with
// the primary Load cell wider; Load | Copy to my presets for an
// embedded one; a 2x3 grid -- Create | Subfolder | Rename over
// Delete | Export | Import -- for a user folder), all cells one
// uniform h-9; phones keep the previous stacking (primary action
// full-width, pairs below). The tree follows BELOW at full width
// and fills every bit of height the tab has left (the tab body's
// h-full flex chain; capped at 420 px only below lg, where the
// page itself scrolls and the cap keeps nested scrolling sane) --
// full-width rows mean preset names never clip against the old
// 58% column's horizontal squeeze. The club-mode scope line and
// the active RANDOM/SEQUENCE pill now share one row (truncate +
// shrink-0) instead of stacking.
// 7.15.2 — Facebook page + theme switch relocation (user request): the
// project now has its own Facebook page, and the footer's
// right-hand group carries the link (facebook.com/openvisengine)
@ -548,4 +574,4 @@
export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.15.2'
export const APP_VERSION = '7.16.0'

View file

@ -2585,3 +2585,30 @@ Stage Summary:
- v7.15.3 ops hardening shipped: .env.ove (portable env template; DATABASE_URL=file:../db/custom.db — the ./ form provably resolves against prisma/ and breaks CLI+runtime), db/custom.db.seed (106-preset baseline) and secrets/ (README + house-cookie slots) are now tracked in the private Forgejo repo; dev.sh self-heals .env (when OVE_DIRECTORY_API_KEY is missing) and db/custom.db (when absent) before bun install, so every future sandbox boot restores the directory proxy and the preset library automatically. E2E-verified against a faithful reset simulation end-to-end.
Post-merge integration (operator commit 3286721, "Track private .env and .env.ove in self-hosted Forgejo"): while this task ran, the operator hand-pushed both files tracked — each with DATABASE_URL="file:./db/custom.db" (quoted, the broken relative form that resolves to prisma/db/custom.db). The merge honors the operator's explicit action — .env STAYS tracked (gitignore gains !.env so the policy is explicit) — with two corrections: DATABASE_URL in BOTH files is file:../db/custom.db (the empirically verified portable form; see the resolution tests above), and the committed .env is held byte-identical to .env.ove so the dev.sh self-heal (which copies the template over .env on every post-reset boot) never leaves a dirty tree. No other content from 3286721 — it carried no gitignore, seed, dev.sh or secrets/ changes.
---
Task ID: 48
Agent: main (Z.ai Code)
Task: v7.16.0 (user request): "On the presets page, instead of having the two sections side by side ... the 'Load Preset' button section needs to be rearranged [so it] is on top with full width use of the preset section, the height set to where there is no empty space being wasted (even when a preset is selected). The preset tree should be placed below that with its width expanded to use the whole width of the Presets section. This way the preset names won't be clipped due to a restriction of horizontal space."
Approach:
- The old layout was a sm:flex-row with the tree at sm:w-[58%] and the management panel in the remaining ~42% -- names truncated inside the narrow column and the panel stretched (align-items default) to the tree's height, leaving dead space under the buttons whenever a preset was selected. The fix is pure rearrangement of the same DOM: column layout, panel first (shrink-0, natural height), tree second (flex-1).
- Compactness comes from spending the new horizontal budget on the button rows: preset selected -> one grid row [Load Preset (1.3fr, primary) | Rename | Delete] (own) or [Load Preset | Copy to my presets] (embedded); read-only folder -> [Export .zip | Import .zip]; own folder -> 2x3 grid [Create | Subfolder | Rename] over [Delete | Export | Import]. All cells uniform h-9 (the old h-8/h-9 mix normalized), minmax(0,fr) tracks + whitespace-nowrap so nothing ever wraps or clips; phones keep the previous stacking (primary action full-width, pairs below) via col-span-2/sm:col-span-1.
- The tree fills the tab body's remaining height on desktop: PresetsTab root becomes flex h-full min-h-0 flex-col (h-full resolves against TabBody's definite lg height; below lg the chain is auto-height so it is inert), tree card flex-1 min-h-[160px], inner scroll div flex-1 with max-h-[420px] lg:max-h-none (the cap survives only where the page itself scrolls). Short viewports overflow the root and the TabBody scrolls -- every control stays reachable.
- Club mode: the scope line and the active RANDOM/SEQUENCE pill now share one row (min-w-0 truncate text + shrink-0 pill), saving the old stacked line.
Work Log:
- presets-tab.tsx: file header comment rewritten for the new layout; root space-y-3 -> flex h-full min-h-0 flex-col gap-3; counts row + management panel shrink-0; panel div- -> shrink-0 card; all four action-button states rebuilt as responsive grids; button heights normalized to h-9; club-mode scope+badge merged into one justify-between row; tree moved below as flex min-h-[160px] flex-1 card with the flex-1/max-h inner scroller. No logic, store, dialog, or tree-row changes -- selection, load, create/copy/rename/delete, zip io and the randomizer are untouched.
- version.ts: APP_VERSION 7.16.0 + history entry. release-notes.ts: 7.16.0 "Presets tab, rearranged" (2 improved, first-person voice, no em dashes).
BROWSER E2E (agent-browser, ALL PASS):
- Desktop 1280x800: panel (y=184) above tree (y=404), both 402 px = full section width; panel natural height 207 px (not stretched); tab body 570 = 570 scrollHeight -- the tree fills the remaining 322 px with NO internal tab scroll; 0 treeitem names clipped (was the whole point of the request).
- Selection states, all one row / 2x3 as designed, uniform 36 px cells, zero button overflow: own preset [Load 142 | Rename 109 | Delete 109], embedded preset [Load 184 | Copy 184], root folder [Export 184 | Import 184], user folder 2x3 [Create 126 | Subfolder 126 | Rename 107] / [Delete 126 | Export 126 | Import 107].
- Flows: Load Preset click -> footer "SCRIPT: Adrealine rush" + emerald running flag in the tree; Copy to my presets -> own-preset row appears; Create Preset dialog -> created + selected + auto-switch to Script tab (pre-existing behavior); Random tick -> "RANDOM · every 10s" pill INLINE with "Random from: user created" (same row), unticked cleanly.
- Tightest widths: 1024x800 (aside 420 px, panel 382 px) -> all grids overflow-free (Load 134 / Rename 103 / Delete 103; folder cells 119/119/101); 800x900 -> 2x3 rows at full width; 375x812 mobile -> panel above tree, both 305 px full width, Load full-row + Rename|Delete pair, folder grid keeps the old 4-row stacking, tree capped at 420 px, no horizontal scroll, footer pushed to the document bottom naturally (docH 1419).
- Version: title "OVE -- Open Visualization Engine (v7.16.0)"; What's New dialog opens with 7.16.0 "Presets tab, rearranged" first; console 0 errors / 0 warnings / no version-drift guard trip; dev.log clean (200s only).
Pre-flight hygiene (before the feature work): the working tree carried three leftover modifications from the v7.15.3 session -- db/custom.db.seed binary drift and 100755 modes on .env.ove + secrets/README.md. Both seeds verified content-identical via Prisma (106 presets / 8 folders / 1 settings, all rows equal), so the seed was reset to the committed baseline and the modes normalized back to 644 -- tree clean before the v7.16.0 commit.
Stage Summary:
- v7.16.0 shipped: the Presets tab's management panel (Load Preset and friends) sits on top at full width and natural height -- no stretched empty space even with a preset selected -- with its buttons packed into one compact grid row on desktop, and the preset tree below it uses the whole section width (filling the tab's leftover height on desktop), so preset names no longer clip. Browser E2E green across every selection state, four viewports, the load/create/copy/randomizer flows and the What's New dialog.