diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 632e9cf..f571a1e 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -91,8 +91,9 @@ } textarea { - min-height: 78px; - padding: 9px; + min-height: 64px; + height: 100%; + padding: 7px; resize: vertical; font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 12px; @@ -298,8 +299,9 @@ .rt-actions { justify-content: flex-end; - min-height: 42px; - padding-block: 6px; + gap: 12px; + min-height: 40px; + padding-block: 5px; flex-wrap: wrap; border-color: oklch(0.22 0.01 260); background: oklch(0.163 0.009 260); @@ -313,9 +315,31 @@ } .rt-action-group { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .rt-action-cluster { display: inline-flex; align-items: center; gap: 6px; + padding-left: 12px; + border-left: 1px solid oklch(0.28 0.01 260); + } + + .rt-action-cluster:first-child { + padding-left: 0; + border-left: 0; + } + + .rt-action-label { + color: oklch(0.58 0.01 260); + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.8px; + text-transform: uppercase; } .rt-toast { @@ -364,17 +388,14 @@ .rt-rail-head, .rt-header-actions, .rt-row-actions, - .rt-track-tools, - .rt-cell-head, - .rt-cell-actions { + .rt-track-head, + .rt-cell-head { display: flex; align-items: center; - gap: 8px; + gap: 5px; } - .rt-rail-head, - .rt-cell-head, - .rt-cell-actions { + .rt-rail-head { justify-content: space-between; } @@ -496,20 +517,20 @@ .rt-block-grid, .rt-launch-grid { display: grid; - gap: 12px; + gap: 8px; } .rt-track, .rt-block, .rt-score, .rt-empty { - padding: 12px; + padding: 10px; } .rt-track { display: grid; grid-template-columns: minmax(190px, 220px) 1fr; - gap: 8px; + gap: 7px; border-color: oklch(0.24 0.01 260); border-left: 3px solid oklch(0.64 0.14 var(--track-hue)); background: oklch(0.16 0.008 260); @@ -519,17 +540,20 @@ .rt-track-gutter { display: grid; align-content: start; - gap: 8px; + gap: 6px; padding: 1px 2px; } - .rt-track-gutter > input { + .rt-track-head input { + min-width: 0; + min-height: 28px; + padding: 0 7px; font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif; font-weight: 600; } - .rt-track-tools { - justify-content: space-between; + .rt-track-head { + width: 100%; } .rt-block-actions, @@ -546,7 +570,7 @@ .rt-ctrls { display: grid; - gap: 9px; + gap: 6px; } .rt-ctrl { @@ -571,8 +595,8 @@ .rt-cell-strip { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 6px; align-items: stretch; } @@ -601,7 +625,7 @@ .rt-cell, .rt-cell-add { - min-height: 172px; + min-height: 138px; border: 1px solid oklch(0.27 0.01 260); border-radius: 8px; background: oklch(0.14 0.008 260); @@ -609,12 +633,14 @@ .rt-cell { display: grid; - gap: 8px; - padding: 10px; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 5px; + padding: 7px; } .rt-drag-handle { - width: 28px; + width: 24px; + min-width: 24px; min-height: 24px; display: inline-flex; align-items: center; @@ -637,9 +663,9 @@ .rt-cell-grip, .rt-cell-select { - width: 28px; - min-width: 28px; - height: 28px; + width: 24px; + min-width: 24px; + height: 24px; min-height: 24px; display: inline-flex; align-items: center; @@ -662,11 +688,11 @@ .rt-cell-select { appearance: auto; - width: 16px; - min-width: 16px; - height: 16px; - min-height: 16px; - margin: 0 6px; + width: 15px; + min-width: 15px; + height: 15px; + min-height: 15px; + margin: 0 3px; padding: 0; border-radius: 50%; color: oklch(0.68 0.03 200); @@ -679,15 +705,16 @@ } .rt-track-grip { - justify-self: start; + flex: 0 0 auto; } .rt-cell-head { - grid-template-columns: auto auto 1fr; + min-height: 26px; } - .rt-cell-state { - justify-self: end; + .rt-cell-spacer { + flex: 1 1 auto; + min-width: 4px; } .rt-track.is-track-drop-before { diff --git a/frontend/src/Riptide/View/Shell.purs b/frontend/src/Riptide/View/Shell.purs index 59fb683..21b37a5 100644 --- a/frontend/src/Riptide/View/Shell.purs +++ b/frontend/src/Riptide/View/Shell.purs @@ -57,14 +57,14 @@ render actions app child = , HH.div [ HP.classes [ HH.ClassName "rt-active" ] ] [ HH.text (activeLabel app) ] ] , HH.div [ HP.classes [ HH.ClassName "rt-actions" ] ] - [ Icons.iconButton "New song" Add actions.newSong - , Icons.iconButton "New toolbox" Add actions.newToolbox - , HH.div [ HP.classes [ HH.ClassName "rt-action-group" ] ] - [ Icons.iconButton "Export song" Download actions.exportSong + [ actionCluster "Song" + [ Icons.iconButton "New song" Add actions.newSong + , Icons.iconButton "Export song" Download actions.exportSong , Icons.iconButton "Import song" Upload actions.importSong ] - , HH.div [ HP.classes [ HH.ClassName "rt-action-group" ] ] - [ Icons.iconButton "Export toolbox" Download actions.exportToolbox + , actionCluster "Toolbox" + [ Icons.iconButton "New toolbox" Add actions.newToolbox + , Icons.iconButton "Export toolbox" Download actions.exportToolbox , Icons.iconButton "Import toolbox" Upload actions.importToolbox ] ] @@ -91,6 +91,13 @@ chipButton classes label action = , HH.span_ [ HH.text label ] ] +actionCluster :: forall action slots m. String -> Array (HH.ComponentHTML action slots m) -> HH.ComponentHTML action slots m +actionCluster label buttons = + HH.div [ HP.classes [ HH.ClassName "rt-action-cluster" ] ] + [ HH.span [ HP.classes [ HH.ClassName "rt-action-label" ] ] [ HH.text label ] + , HH.div [ HP.classes [ HH.ClassName "rt-action-group" ] ] buttons + ] + scopeClasses :: App -> Array HH.ClassName scopeClasses app = [ HH.ClassName "rt-chip", HH.ClassName "rt-scope", if scopeInvalid app then HH.ClassName "is-invalid" else HH.ClassName "is-valid" ] diff --git a/frontend/src/Riptide/View/Song.purs b/frontend/src/Riptide/View/Song.purs index 9bfd1f5..5000cf0 100644 --- a/frontend/src/Riptide/View/Song.purs +++ b/frontend/src/Riptide/View/Song.purs @@ -153,23 +153,23 @@ trackRow actions app track = , HE.onDrop (actions.dropOn target) ] [ HH.div [ HP.classes [ HH.ClassName "rt-track-gutter" ] ] - [ HH.button - [ HP.type_ HP.ButtonButton - , HP.title "Drag track" - , HP.classes [ HH.ClassName "rt-drag-handle", HH.ClassName "rt-track-grip" ] - , HP.draggable true - , HE.onDragStart \_ -> actions.startTrackDrag track.id - , HE.onDragEnd \_ -> actions.endDrag - ] - [ Icons.icon Grip ] - , HH.input - [ HP.type_ HP.InputText - , HP.value track.name - , HE.onValueInput (actions.renameTrack track.id) - ] - , HH.div [ HP.classes [ HH.ClassName "rt-track-tools" ] ] + [ HH.div [ HP.classes [ HH.ClassName "rt-track-head" ] ] ( - [ Icons.iconButton "Stop track" Stop (actions.stopTrack track.id) + [ HH.button + [ HP.type_ HP.ButtonButton + , HP.title "Drag track" + , HP.classes [ HH.ClassName "rt-drag-handle", HH.ClassName "rt-track-grip" ] + , HP.draggable true + , HE.onDragStart \_ -> actions.startTrackDrag track.id + , HE.onDragEnd \_ -> actions.endDrag + ] + [ Icons.icon Grip ] + , HH.input + [ HP.type_ HP.InputText + , HP.value track.name + , HE.onValueInput (actions.renameTrack track.id) + ] + , Icons.iconButton "Stop track" Stop (actions.stopTrack track.id) ] <> confirmDeleteButtons "track" confirming (actions.deleteTrack track.id) actions.cancelConfirm ) @@ -216,6 +216,7 @@ cellTile actions app track cell = , HE.onDrop (actions.dropOn target) ] [ HH.div [ HP.classes [ HH.ClassName "rt-cell-head" ] ] + ( [ HH.button [ HP.type_ HP.ButtonButton , HP.title "Drag cell" @@ -236,7 +237,14 @@ cellTile actions app track cell = , HE.onClick \_ -> actions.selectCell track.id cell.id ] , HH.span [ HP.classes [ HH.ClassName "rt-cell-state" ] ] [ HH.text (cellStateLabel result active selected editing) ] + , HH.div [ HP.classes [ HH.ClassName "rt-cell-spacer" ] ] [] + , Icons.iconButtonDisabled (if active then "Stop cell" else "Launch cell") + (if active then Stop else Play) + launchDisabled + (actions.toggleCell track.id cell.id) ] + <> confirmDeleteButtons "cell" confirming (actions.deleteCell track.id cell.id) actions.cancelConfirm + ) , HH.textarea [ HP.value cell.code , HP.rows 3 @@ -245,15 +253,6 @@ cellTile actions app track cell = , HE.onBlur \_ -> actions.blurCell cell.id , HE.onValueInput (actions.editCode track.id cell.id) ] - , HH.div [ HP.classes [ HH.ClassName "rt-cell-actions" ] ] - ( - [ Icons.iconButtonDisabled (if active then "Stop cell" else "Launch cell") - (if active then Stop else Play) - launchDisabled - (actions.toggleCell track.id cell.id) - ] - <> confirmDeleteButtons "cell" confirming (actions.deleteCell track.id cell.id) actions.cancelConfirm - ) , case result.error of Just err -> HH.div [ HP.classes [ HH.ClassName "rt-cell-error" ] ] [ HH.text err ] diff --git a/gate.sh b/gate.sh index 674ec44..8117be2 100755 --- a/gate.sh +++ b/gate.sh @@ -281,8 +281,26 @@ async function assertFrontendInteractions(cdp) { if (tracks.length === 0) failures.push("missing tracks"); for (const track of tracks) { - const radios = [...track.querySelectorAll(".rt-cell-head input.rt-cell-select[type='radio']")]; const cells = [...track.querySelectorAll(".rt-cell")]; + const compactRows = [...track.querySelectorAll(".rt-cell > .rt-cell-head")]; + const legacyActionRows = [...track.querySelectorAll(".rt-cell > .rt-cell-actions")]; + if (compactRows.length !== cells.length) { + failures.push("track has " + compactRows.length + " compact cell strips for " + cells.length + " cells"); + } + if (legacyActionRows.length !== 0) { + failures.push("legacy .rt-cell-actions row still exists"); + } + for (const cell of cells) { + const strip = cell.querySelector(":scope > .rt-cell-head"); + if (!strip?.querySelector(".rt-cell-grip")) failures.push("compact cell strip is missing drag grip"); + if (!strip?.querySelector("input.rt-cell-select[type='radio']")) failures.push("compact cell strip is missing native radio"); + if (!strip?.querySelector(".rt-cell-state")) failures.push("compact cell strip is missing state badge"); + if (!strip?.querySelector(".rt-cell-spacer")) failures.push("compact cell strip is missing flex spacer"); + if (!strip?.querySelector("button[title='Launch cell'], button[title='Stop cell']")) failures.push("compact cell strip is missing launch/stop action"); + if (!strip?.querySelector(".rt-danger[title='Delete cell'], .rt-danger[title='Confirm delete cell']")) failures.push("compact cell strip is missing delete action"); + } + + const radios = [...track.querySelectorAll(".rt-cell-head input.rt-cell-select[type='radio']")]; if (radios.length !== cells.length) { failures.push("track has " + radios.length + " radio selectors for " + cells.length + " cells"); continue; @@ -325,6 +343,43 @@ async function assertFrontendInteractions(cdp) { failures.push("old eye select button still exists in cell headers"); } + if (document.querySelector(".rt-track-tools")) { + failures.push("legacy .rt-track-tools wrapper still exists"); + } + for (const track of tracks) { + const row = track.querySelector(".rt-track-gutter > .rt-track-head"); + if (!row) { + failures.push("track gutter is missing compact track head"); + continue; + } + if (!row.querySelector(".rt-track-grip")) failures.push("compact track head is missing drag grip"); + if (!row.querySelector("input")) failures.push("compact track head is missing track name input"); + if (!row.querySelector("button[title='Stop track']")) failures.push("compact track head is missing stop action"); + if (!row.querySelector(".rt-danger[title='Delete track'], .rt-danger[title='Confirm delete track']")) failures.push("compact track head is missing delete action"); + const ctrls = track.querySelector(".rt-track-gutter > .rt-ctrls"); + if (!ctrls || row.compareDocumentPosition(ctrls) !== Node.DOCUMENT_POSITION_FOLLOWING) { + failures.push("track sliders are not below the compact track head"); + } + } + + const songGroup = [...document.querySelectorAll(".rt-action-cluster")] + .find((group) => group.querySelector(".rt-action-label")?.textContent.trim() === "Song"); + const toolboxGroup = [...document.querySelectorAll(".rt-action-cluster")] + .find((group) => group.querySelector(".rt-action-label")?.textContent.trim() === "Toolbox"); + if (!songGroup) failures.push("missing labeled Song global action cluster"); + if (!toolboxGroup) failures.push("missing labeled Toolbox global action cluster"); + for (const [label, group, titles] of [ + ["Song", songGroup, ["New song", "Export song", "Import song"]], + ["Toolbox", toolboxGroup, ["New toolbox", "Export toolbox", "Import toolbox"]], + ]) { + if (!group) continue; + for (const title of titles) { + if (!group.querySelector("button[title='" + title + "'], button[aria-label='" + title + "']")) { + failures.push(label + " global action cluster is missing " + title); + } + } + } + for (const grip of document.querySelectorAll(".rt-drag-handle")) { const circles = [...grip.querySelectorAll("svg circle")]; const lines = [...grip.querySelectorAll("svg line")]; @@ -350,17 +405,17 @@ async function assertFrontendInteractions(cdp) { await evaluateOrThrow(cdp, `(() => { const cell = document.querySelector(".rt-cell.is-stopped.has-text-idle.is-valid"); - const button = cell?.querySelector('.rt-cell-actions button[title="Launch cell"]:not(:disabled)'); + const button = cell?.querySelector('.rt-cell-head button[title="Launch cell"]:not(:disabled)'); if (!cell || !button) throw new Error("missing enabled launchable stopped cell action"); button.click(); })()`); - await waitForCondition(cdp, `(() => Boolean(document.querySelector(".rt-cell.is-active-playing .rt-cell-actions button[title='Stop cell']:not(:disabled)")))()`, 2000, "launchable cell did not become active with enabled stop action"); + await waitForCondition(cdp, `(() => Boolean(document.querySelector(".rt-cell.is-active-playing .rt-cell-head button[title='Stop cell']:not(:disabled)")))()`, 2000, "launchable cell did not become active with enabled stop action"); await evaluateOrThrow(cdp, `(() => { - const button = document.querySelector(".rt-cell.is-active-playing .rt-cell-actions button[title='Stop cell']:not(:disabled)"); + const button = document.querySelector(".rt-cell.is-active-playing .rt-cell-head button[title='Stop cell']:not(:disabled)"); if (!button) throw new Error("missing enabled stop action for active cell"); button.click(); })()`); - await waitForCondition(cdp, `(() => Boolean(document.querySelector(".rt-cell.is-stopped .rt-cell-actions button[title='Launch cell']:not(:disabled)")))()`, 2000, "second launch click did not stop/un-arm the active cell"); + await waitForCondition(cdp, `(() => Boolean(document.querySelector(".rt-cell.is-stopped .rt-cell-head button[title='Launch cell']:not(:disabled)")))()`, 2000, "second launch click did not stop/un-arm the active cell"); await evaluateOrThrow(cdp, `(() => { const cell = [...document.querySelectorAll(".rt-cell")] diff --git a/specs/037-compact-controls/plan.md b/specs/037-compact-controls/plan.md new file mode 100644 index 0000000..c6c5d37 --- /dev/null +++ b/specs/037-compact-controls/plan.md @@ -0,0 +1,32 @@ +# Implementation Plan + +## Tech stack + +- PureScript Halogen frontend. +- Static CSS in `frontend/dist/index.html`. +- Existing repository gate in `gate.sh`, including headless Chromium interaction smoke. + +## Slice strategy + +This is one vertical frontend cleanup slice. The markup and CSS changes are tightly coupled, and the render smoke must move with the renamed/removed control containers. + +## Slice 1: compact controls and global labels + +Owned implementation surface: + +- `frontend/src/Riptide/View/Song.purs` +- `frontend/src/Riptide/View/Shell.purs` +- `frontend/dist/index.html` +- `gate.sh` + +Expected changes: + +- In `Song.purs`, restructure cell tile controls into one strip and track gutter controls into one row above sliders. +- In `Shell.purs`, group top-bar global actions into labeled Song and Toolbox clusters with visible labels and retained tooltips. +- In `index.html`, tighten spacing and sizes for cells, tracks, controls, and global action groups. +- In `gate.sh`, update smoke selectors to assert the new control structure and continue verifying preserved behavior. + +## Verification + +- Run `./gate.sh`. +- Inspect the browser-rendered frontend in headless smoke, with selectors proving the layout is compact and the core interactions still work. diff --git a/specs/037-compact-controls/spec.md b/specs/037-compact-controls/spec.md new file mode 100644 index 0000000..972e347 --- /dev/null +++ b/specs/037-compact-controls/spec.md @@ -0,0 +1,27 @@ +# Issue 37: compact controls and labeled global actions + +## P1 user story + +As a riptide user arranging tracks and cells, I need each editable unit to keep its controls in a compact, predictable place so the code area stays prominent and global song/toolbox actions are unambiguous. + +## Scope + +- Collapse each cell's header/actions into one compact strip. +- Collapse each track gutter's grip/name/stop/delete controls into one compact row above the existing VOL/FLT/DLY sliders. +- Reduce empty padding, gaps, and min-height where the current layout creates unused space. +- Group top-bar global actions into visible Song and Toolbox clusters, preserving tooltips. + +## Functional requirements + +- FR-037-001: Cell markup has a single top strip containing the drag grip, native radio selector, state badge, spacer, play/stop button, and delete/confirm/cancel controls. +- FR-037-002: The separate bottom `.rt-cell-actions` row is removed; the textarea occupies the remaining vertical space. +- FR-037-003: Track gutter markup has a compact row containing drag grip, track name, stop, and delete/confirm/cancel controls, followed by the existing control sliders. +- FR-037-004: The separate `.rt-track-tools` box is removed. +- FR-037-005: Global action buttons are grouped into labeled Song and Toolbox clusters: new, export, import. +- FR-037-006: Existing interaction behavior remains intact: radio select is mutually exclusive per track, drag grips still drag, play/stop still toggles/un-arms a cell, and delete remains two-step with cancel. + +## Success criteria + +- `./gate.sh` passes. +- Browser smoke verifies one compact strip per cell, no legacy `.rt-cell-actions` row, no `.rt-track-tools` wrapper, labeled Song/Toolbox action clusters, and preserved interactions. +- `nix build .#frontend` passes. diff --git a/specs/037-compact-controls/tasks.md b/specs/037-compact-controls/tasks.md new file mode 100644 index 0000000..f6ff9e9 --- /dev/null +++ b/specs/037-compact-controls/tasks.md @@ -0,0 +1,10 @@ +# Tasks + +## Slice 1 - compact controls and global labels + +- [X] T037-S1 Restructure cell controls into a single compact top strip and remove the separate bottom action row. +- [X] T037-S1 Restructure track gutter controls into one compact top row and remove the separate track tools box. +- [X] T037-S1 Group global action buttons into visible Song and Toolbox clusters with retained tooltips. +- [X] T037-S1 Tighten CSS spacing, gaps, padding, and minimum heights so cell textareas use the available space. +- [X] T037-S1 Update render-smoke selectors for the new markup while preserving radio, drag glyph, play/stop, and delete/cancel checks. +- [X] T037-S1 Run `./gate.sh` and commit one bisect-safe slice with the required trailer.