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.