From a309f3fea87fb14b08df028d3607d72cc6ffeeb9 Mon Sep 17 00:00:00 2001 From: Paolo Veronelli Date: Sat, 4 Jul 2026 12:51:57 +0100 Subject: [PATCH 1/4] docs: plan explicit delete cancel Tasks: T034-plan --- specs/034-delete-cancel/plan.md | 26 ++++++++++++++++++++++++++ specs/034-delete-cancel/spec.md | 24 ++++++++++++++++++++++++ specs/034-delete-cancel/tasks.md | 8 ++++++++ 3 files changed, 58 insertions(+) create mode 100644 specs/034-delete-cancel/plan.md create mode 100644 specs/034-delete-cancel/spec.md create mode 100644 specs/034-delete-cancel/tasks.md diff --git a/specs/034-delete-cancel/plan.md b/specs/034-delete-cancel/plan.md new file mode 100644 index 0000000..d49d240 --- /dev/null +++ b/specs/034-delete-cancel/plan.md @@ -0,0 +1,26 @@ +# Plan + +## Stack +- PureScript / Halogen frontend. +- Hand-written CSS in `frontend/dist/index.html`. +- Pure reducer tests in `frontend/test/Main.purs`. +- Browser interaction smoke embedded in `gate.sh`. + +## Slice 1: delete cancel + centered icons +Implement one bisect-safe frontend slice because the reducer, views, CSS, and smoke test are tightly coupled: + +- Add a pure disarm function such as `cancelConfirm :: App -> App` in `Riptide.Reducer` and expose it. +- Add an app action for cancel/disarm and route it from: + - explicit cancel buttons shown beside armed delete buttons, + - `Esc`, + - clicks outside the active confirm affordance. +- Add a cancel glyph or use a clear existing icon pattern; do not add dependencies. +- Ensure every delete surface shows both "Confirm delete ..." and an adjacent cancel button when armed. +- Keep the second click on "Confirm delete ..." as the only delete confirmation path. +- Center all icon glyphs via CSS and stable button dimensions. +- Extend reducer and smoke tests. + +## Verification +- Focused RED/GREEN: `nix develop --command just -f frontend/justfile test` from repo root if needed, or `cd frontend && nix develop .. --command just unit` if that is the local pattern. +- Full gate: `./gate.sh`. +- Manual browser proof through the smoke: arm -> cancel leaves target; arm -> confirm removes target; centered icon assertions pass. diff --git a/specs/034-delete-cancel/spec.md b/specs/034-delete-cancel/spec.md new file mode 100644 index 0000000..81a45dc --- /dev/null +++ b/specs/034-delete-cancel/spec.md @@ -0,0 +1,24 @@ +# Issue 34: explicit armed-delete cancel + +## P1 Story +A user who accidentally arms a destructive delete can back out immediately without waiting for the timeout and without risking a second click deleting data. + +## Scope +- Frontend only. +- Applies to every gated delete: song, toolbox, track, cell, and block. +- Fold in the added CSS pass: icon glyphs must be visually centered inside their button boxes. + +## Functional Requirements +- When `confirm` is set for a delete key, the UI shows a cancel affordance next to that item's "Confirm delete" action. +- Activating cancel clears `confirm` immediately and does not delete the item. +- Pressing `Esc` clears an armed confirmation. +- Clicking elsewhere clears an armed confirmation. +- The existing 2800ms auto-cancel remains as a fallback. +- Confirming by clicking the armed delete button still deletes the target. +- Icon buttons, drag grips, select buttons, transport buttons, rail buttons, top-bar buttons, and add-cell buttons center their SVG glyphs in a stable box. + +## Success Criteria +- Unit coverage proves reducer-level disarm if a pure reducer function is added. +- Browser smoke proves arm -> cancel keeps the item and clears confirm UI, and arm -> confirm still deletes. +- Browser smoke includes practical evidence that icon glyphs are visible and centered in their button boxes. +- `./gate.sh` passes. diff --git a/specs/034-delete-cancel/tasks.md b/specs/034-delete-cancel/tasks.md new file mode 100644 index 0000000..eabe65d --- /dev/null +++ b/specs/034-delete-cancel/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +## Slice 1 - Delete cancel and icon centering +- [ ] T034-S1 Add reducer disarm coverage and implementation. +- [ ] T034-S1 Wire cancel action through all delete views and the top-level app interactions. +- [ ] T034-S1 Center icon glyphs in all button boxes without regressing click behavior. +- [ ] T034-S1 Extend browser smoke for arm/cancel, arm/confirm, and practical icon centering checks. +- [ ] T034-S1 Run `./gate.sh` and commit one bisect-safe changeset. From 5310eca7119ebc06f054162371c06912d9f45e27 Mon Sep 17 00:00:00 2001 From: Paolo Veronelli Date: Sat, 4 Jul 2026 12:58:42 +0100 Subject: [PATCH 2/4] docs: expand cell control cleanup plan Tasks: T034-plan --- specs/034-delete-cancel/plan.md | 16 +++++++++++++--- specs/034-delete-cancel/spec.md | 9 +++++++-- specs/034-delete-cancel/tasks.md | 7 +++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/specs/034-delete-cancel/plan.md b/specs/034-delete-cancel/plan.md index d49d240..22f4a53 100644 --- a/specs/034-delete-cancel/plan.md +++ b/specs/034-delete-cancel/plan.md @@ -7,7 +7,7 @@ - Browser interaction smoke embedded in `gate.sh`. ## Slice 1: delete cancel + centered icons -Implement one bisect-safe frontend slice because the reducer, views, CSS, and smoke test are tightly coupled: +Implement the destructive-action safety fix and broad icon-centering pass: - Add a pure disarm function such as `cancelConfirm :: App -> App` in `Riptide.Reducer` and expose it. - Add an app action for cancel/disarm and route it from: @@ -20,7 +20,17 @@ Implement one bisect-safe frontend slice because the reducer, views, CSS, and sm - Center all icon glyphs via CSS and stable button dimensions. - Extend reducer and smoke tests. +## Slice 2: cell control cleanup +After Slice 1 lands, clean up the cell header controls: + +- Replace the eye/select icon button with a real radio input per cell. +- Group radios by track, with the checked radio matching `track.selected`; clicking a radio calls existing `selectCell`. +- Change the grip glyph from pause-like `||` to a six-dot grip-vertical icon. +- Keep the grip visually subtle and preserve drag-and-drop from the grip. +- Tighten CSS for the target cell header: grip, radio, state badge; bottom actions remain play and delete. +- Extend browser smoke for radio mutual exclusion, grip glyph shape, and clean centered icon geometry. + ## Verification -- Focused RED/GREEN: `nix develop --command just -f frontend/justfile test` from repo root if needed, or `cd frontend && nix develop .. --command just unit` if that is the local pattern. +- Focused RED/GREEN: `nix develop .#frontend --command spago test` from repo root. - Full gate: `./gate.sh`. -- Manual browser proof through the smoke: arm -> cancel leaves target; arm -> confirm removes target; centered icon assertions pass. +- Manual browser proof through the smoke: arm -> cancel leaves target; arm -> confirm removes target; centered icon, radio, and grip assertions pass. diff --git a/specs/034-delete-cancel/spec.md b/specs/034-delete-cancel/spec.md index 81a45dc..f7e1026 100644 --- a/specs/034-delete-cancel/spec.md +++ b/specs/034-delete-cancel/spec.md @@ -6,7 +6,7 @@ A user who accidentally arms a destructive delete can back out immediately witho ## Scope - Frontend only. - Applies to every gated delete: song, toolbox, track, cell, and block. -- Fold in the added CSS pass: icon glyphs must be visually centered inside their button boxes. +- Fold in the added CSS/cell-control pass: icon glyphs must be visually centered inside their button boxes, the cell grip must read as a real drag handle, and cell selection must use per-track radios instead of the redundant eye button. ## Functional Requirements - When `confirm` is set for a delete key, the UI shows a cancel affordance next to that item's "Confirm delete" action. @@ -15,10 +15,15 @@ A user who accidentally arms a destructive delete can back out immediately witho - Clicking elsewhere clears an armed confirmation. - The existing 2800ms auto-cancel remains as a fallback. - Confirming by clicking the armed delete button still deletes the target. -- Icon buttons, drag grips, select buttons, transport buttons, rail buttons, top-bar buttons, and add-cell buttons center their SVG glyphs in a stable box. +- Icon buttons, drag grips, transport buttons, rail buttons, top-bar buttons, and add-cell buttons center their SVG glyphs in a stable box. +- Cell headers render as grip, radio, state badge; bottom actions remain play and delete. +- Each track's cells form one radio group. The checked radio is the selected cell the score fires for that track, and clicking a radio calls the existing `selectCell`. +- The cell drag grip uses a subtle grip-vertical six-dot glyph, not a pause-like two-line glyph. ## Success Criteria - Unit coverage proves reducer-level disarm if a pure reducer function is added. - Browser smoke proves arm -> cancel keeps the item and clears confirm UI, and arm -> confirm still deletes. - Browser smoke includes practical evidence that icon glyphs are visible and centered in their button boxes. +- Browser smoke proves radio selection is mutually exclusive per track. +- Browser smoke proves the grip icon no longer uses the pause-like two-line glyph. - `./gate.sh` passes. diff --git a/specs/034-delete-cancel/tasks.md b/specs/034-delete-cancel/tasks.md index eabe65d..414fa55 100644 --- a/specs/034-delete-cancel/tasks.md +++ b/specs/034-delete-cancel/tasks.md @@ -6,3 +6,10 @@ - [ ] T034-S1 Center icon glyphs in all button boxes without regressing click behavior. - [ ] T034-S1 Extend browser smoke for arm/cancel, arm/confirm, and practical icon centering checks. - [ ] T034-S1 Run `./gate.sh` and commit one bisect-safe changeset. + +## Slice 2 - Cell control cleanup +- [ ] T034-S2 Replace the cell eye select button with per-track radio groups wired to `selectCell`. +- [ ] T034-S2 Replace the pause-like grip glyph with a subtle six-dot drag handle. +- [ ] T034-S2 Adjust cell header CSS to read cleanly as grip, radio, state badge. +- [ ] T034-S2 Extend browser smoke for radio mutual exclusion, grip shape, centered glyphs, and clean render. +- [ ] T034-S2 Run `./gate.sh` and commit one bisect-safe changeset. From b77d3ae783c1a2dfb1b134e1fe22cb98fa7bb86b Mon Sep 17 00:00:00 2001 From: Paolo Veronelli Date: Sat, 4 Jul 2026 13:13:13 +0100 Subject: [PATCH 3/4] fix(frontend): add delete cancel affordance Tasks: T034-S1 --- frontend/dist/index.html | 16 +++-- frontend/src/Riptide/App.purs | 81 ++++++++++++++++++---- frontend/src/Riptide/Model.purs | 2 + frontend/src/Riptide/Reducer.purs | 7 +- frontend/src/Riptide/View/Definitions.purs | 33 ++++++--- frontend/src/Riptide/View/Icons.purs | 21 +++++- frontend/src/Riptide/View/Shell.purs | 10 ++- frontend/src/Riptide/View/Song.purs | 41 +++++++---- frontend/test/Main.purs | 13 ++++ gate.sh | 62 +++++++++++++++-- specs/034-delete-cancel/tasks.md | 10 +-- 11 files changed, 240 insertions(+), 56 deletions(-) diff --git a/frontend/dist/index.html b/frontend/dist/index.html index b038e67..c5daa82 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -208,8 +208,9 @@ min-width: 34px; height: 34px; min-height: 34px; - display: inline-grid; - place-items: center; + display: inline-flex; + align-items: center; + justify-content: center; padding: 0; border-radius: 7px; line-height: 1; @@ -630,9 +631,11 @@ .rt-cell-select { width: 28px; min-width: 28px; + height: 28px; min-height: 24px; - display: inline-grid; - place-items: center; + display: inline-flex; + align-items: center; + justify-content: center; padding: 0; line-height: 1; } @@ -681,8 +684,9 @@ } .rt-cell-add { - display: grid; - place-items: center; + display: flex; + align-items: center; + justify-content: center; border-style: dashed; color: oklch(0.6 0.01 260); background: transparent; diff --git a/frontend/src/Riptide/App.purs b/frontend/src/Riptide/App.purs index 75b9941..da7dc85 100644 --- a/frontend/src/Riptide/App.purs +++ b/frontend/src/Riptide/App.purs @@ -28,9 +28,17 @@ import Riptide.WebSocket as WebSocket import Web.Event.Event as Event import Web.HTML.Event.DragEvent (DragEvent) import Web.HTML.Event.DragEvent as DragEvent +import Web.UIEvent.KeyboardEvent (KeyboardEvent) +import Web.UIEvent.KeyboardEvent as KeyboardEvent +import Web.UIEvent.MouseEvent (MouseEvent) +import Web.UIEvent.MouseEvent as MouseEvent data Action = Initialize + | CancelConfirm + | CancelConfirmClick MouseEvent + | ConfirmTimeout H.SubscriptionId String Int + | ShellKeyDown KeyboardEvent | GoSong | GoDefs | ToggleEngine @@ -50,24 +58,24 @@ data Action | RenameToolbox ToolboxId String | DuplicateSong SongId | DuplicateToolbox ToolboxId - | DeleteSong SongId - | DeleteToolbox ToolboxId + | DeleteSong SongId MouseEvent + | DeleteToolbox ToolboxId MouseEvent | AddBlock | RenameBlock BlockId String | EditBlockCode BlockId String | ApplyBlock BlockId | ApplyAll - | DeleteBlock BlockId + | DeleteBlock BlockId MouseEvent | RenameTrack TrackId String | SetCtrl TrackId ControlKey String | StopTrack TrackId | AddTrack - | DeleteTrack TrackId + | DeleteTrack TrackId MouseEvent | AddCell TrackId | SelectCell TrackId CellId | ToggleCell TrackId CellId | EditCode TrackId CellId String - | DeleteCell TrackId CellId + | DeleteCell TrackId CellId MouseEvent | StartEdit String String | StopEdit | FocusCell CellId @@ -115,6 +123,8 @@ shellActions = , importSong: ImportSong , exportToolbox: ExportToolbox , importToolbox: ImportToolbox + , cancelConfirm: CancelConfirm + , keyDown: ShellKeyDown } songActions :: Song.SongActions Action @@ -124,6 +134,7 @@ songActions = , renameSong: RenameSong , duplicateSong: DuplicateSong , deleteSong: DeleteSong + , cancelConfirm: CancelConfirmClick , renameTrack: RenameTrack , setCtrl: SetCtrl , stopTrack: StopTrack @@ -160,6 +171,7 @@ definitionsActions = , renameToolbox: RenameToolbox , duplicateToolbox: DuplicateToolbox , deleteToolbox: DeleteToolbox + , cancelConfirm: CancelConfirmClick , addBlock: AddBlock , renameBlock: RenameBlock , editBlockCode: EditBlockCode @@ -177,6 +189,22 @@ handleAction = case _ of H.modify_ (Reducer.setConnection Connecting) subscribeWebSocket when app.playing startPlayhead + CancelConfirm -> + H.modify_ Reducer.cancelConfirm + CancelConfirmClick event -> do + stopMouseEvent event + H.modify_ Reducer.cancelConfirm + ConfirmTimeout subscriptionId key token -> do + H.unsubscribe subscriptionId + H.modify_ \app -> + if app.confirm == Just key && app.confirmToken == token then + Reducer.cancelConfirm app + else + app + ShellKeyDown event -> + when (KeyboardEvent.key event == "Escape") do + H.liftEffect (Event.preventDefault (KeyboardEvent.toEvent event)) + H.modify_ Reducer.cancelConfirm GoSong -> H.modify_ \app -> case app.currentSongId of @@ -291,10 +319,10 @@ handleAction = case _ of H.modify_ (Reducer.duplicateToolbox toolboxId { toolboxId: newToolboxId, blockIds }) Nothing -> pure unit - DeleteSong songId -> - H.modify_ (Reducer.deleteSong songId) - DeleteToolbox toolboxId -> - H.modify_ (Reducer.deleteToolbox toolboxId) + DeleteSong songId event -> + applyDelete event (Reducer.deleteSong songId) + DeleteToolbox toolboxId event -> + applyDelete event (Reducer.deleteToolbox toolboxId) AddBlock -> do blockId <- H.liftEffect (mintId "b") H.modify_ (Reducer.addBlock blockId) @@ -322,8 +350,8 @@ handleAction = case _ of H.modify_ Reducer.applyAll app <- H.get traverse_ sendDefinitionAndApply (currentBlocks app) - DeleteBlock blockId -> - H.modify_ (Reducer.deleteBlock blockId) + DeleteBlock blockId event -> + applyDelete event (Reducer.deleteBlock blockId) RenameTrack trackId name -> H.modify_ (Reducer.renameTrack trackId name) SetCtrl trackId key raw -> @@ -338,8 +366,8 @@ handleAction = case _ of AddTrack -> do trackId <- H.liftEffect (mintId "t") H.modify_ (Reducer.addTrack trackId) - DeleteTrack trackId -> - H.modify_ (Reducer.removeTrack trackId) + DeleteTrack trackId event -> + applyDelete event (Reducer.removeTrack trackId) AddCell trackId -> do cellId <- H.liftEffect (mintId "c") H.modify_ (Reducer.addCell trackId cellId) @@ -357,8 +385,8 @@ handleAction = case _ of H.modify_ (Reducer.editCode trackId cellId code) sendWhenConnected (Protocol.SaveTrackText trackId cellId code) sendWhenConnected (Protocol.ValidateText code) - DeleteCell trackId cellId -> - H.modify_ (Reducer.removeCell trackId cellId) + DeleteCell trackId cellId event -> + applyDelete event (Reducer.removeCell trackId cellId) StartEdit kind id -> H.modify_ (Reducer.startEdit kind id) StopEdit -> @@ -594,6 +622,29 @@ sendDefinitionAndApply block = do sendWhenConnected (Protocol.SaveDefinition block.id block.name block.code) sendWhenConnected (Protocol.ApplyDefinition block.id) +applyDelete :: forall output m. MonadAff m => MouseEvent -> (App -> App) -> H.HalogenM App Action () output m Unit +applyDelete event reducer = do + stopMouseEvent event + before <- H.get + let + after = reducer before + H.put after + scheduleConfirmTimeout before after + +scheduleConfirmTimeout :: forall output m. MonadAff m => App -> App -> H.HalogenM App Action () output m Unit +scheduleConfirmTimeout before after = + case after.confirm of + Just key + | before.confirm /= after.confirm || before.confirmToken /= after.confirmToken -> + H.subscribe' \subscriptionId -> + Files.timeoutEmitter 2800 (ConfirmTimeout subscriptionId key after.confirmToken) + _ -> + pure unit + +stopMouseEvent :: forall output m. MonadAff m => MouseEvent -> H.HalogenM App Action () output m Unit +stopMouseEvent event = + H.liftEffect (Event.stopPropagation (MouseEvent.toEvent event)) + fileName :: String -> String -> String fileName name kind = name <> ".riptide-" <> kind <> ".json" diff --git a/frontend/src/Riptide/Model.purs b/frontend/src/Riptide/Model.purs index 9d50414..688711b 100644 --- a/frontend/src/Riptide/Model.purs +++ b/frontend/src/Riptide/Model.purs @@ -97,6 +97,7 @@ type App = , hoverCell :: Maybe CellId , focusCell :: Maybe CellId , confirm :: Maybe String + , confirmToken :: Int , editing :: Maybe EditingTarget , drag :: Maybe DragState , over :: Maybe DropTarget @@ -187,6 +188,7 @@ defaultApp = , hoverCell: Nothing , focusCell: Nothing , confirm: Nothing + , confirmToken: 0 , editing: Nothing , drag: Nothing , over: Nothing diff --git a/frontend/src/Riptide/Reducer.purs b/frontend/src/Riptide/Reducer.purs index 90f138f..0966e42 100644 --- a/frontend/src/Riptide/Reducer.purs +++ b/frontend/src/Riptide/Reducer.purs @@ -10,6 +10,7 @@ module Riptide.Reducer , deleteBlock , deleteSong , deleteToolbox + , cancelConfirm , duplicateSong , duplicateToolbox , editBlockCode @@ -437,6 +438,10 @@ endResizeScore :: App -> App endResizeScore app = app { resizing = false } +cancelConfirm :: App -> App +cancelConfirm app = + app { confirm = Nothing } + setPaint :: TrackId -> Int -> Boolean -> App -> App setPaint trackId bar value = mapTrack trackId \track -> @@ -517,7 +522,7 @@ armDelete key action app = if app.confirm == Just key then (action app) { confirm = Nothing } else - app { confirm = Just key } + app { confirm = Just key, confirmToken = app.confirmToken + 1 } mapCurrentSongTracks :: (Track -> Track) -> App -> App mapCurrentSongTracks f = diff --git a/frontend/src/Riptide/View/Definitions.purs b/frontend/src/Riptide/View/Definitions.purs index 9517986..ddc3b57 100644 --- a/frontend/src/Riptide/View/Definitions.purs +++ b/frontend/src/Riptide/View/Definitions.purs @@ -15,19 +15,21 @@ import Riptide.Model (App, Block, BlockId, EditingTarget, Toolbox, ToolboxId) import Riptide.Validation (ValidationResult, authoritativeValidation) import Riptide.View.Icons (Icon(..)) import Riptide.View.Icons as Icons +import Web.UIEvent.MouseEvent (MouseEvent) type DefinitionsActions action = { newToolbox :: action , openToolbox :: ToolboxId -> action , renameToolbox :: ToolboxId -> String -> action , duplicateToolbox :: ToolboxId -> action - , deleteToolbox :: ToolboxId -> action + , deleteToolbox :: ToolboxId -> MouseEvent -> action + , cancelConfirm :: MouseEvent -> action , addBlock :: action , renameBlock :: BlockId -> String -> action , editBlockCode :: BlockId -> String -> action , applyBlock :: BlockId -> action , applyAll :: action - , deleteBlock :: BlockId -> action + , deleteBlock :: BlockId -> MouseEvent -> action , startEdit :: String -> String -> action , stopEdit :: action } @@ -81,13 +83,13 @@ toolboxRow actions app toolbox = , HH.small_ [ HH.text (toolboxMeta app toolbox) ] ] , HH.div [ HP.classes [ HH.ClassName "rt-row-actions" ] ] + ( [ Icons.iconButton "Open toolbox" Eye (actions.openToolbox toolbox.id) , Icons.iconButton "Rename toolbox" Edit (actions.startEdit "tbx" toolbox.id) , Icons.iconButton "Duplicate toolbox" Copy (actions.duplicateToolbox toolbox.id) - , Icons.dangerButton (if confirming then "Confirm delete toolbox" else "Delete toolbox") - (if confirming then Check else Delete) - (actions.deleteToolbox toolbox.id) ] + <> confirmDeleteButtons "toolbox" confirming (actions.deleteToolbox toolbox.id) actions.cancelConfirm + ) ] toolboxShell :: forall action slots m. DefinitionsActions action -> App -> Toolbox -> Array (HH.ComponentHTML action slots m) @@ -142,11 +144,11 @@ blockCard actions app block = , HH.span [ HP.classes [ HH.ClassName "rt-badge" ] ] [ HH.text (show impact.count <> " live uses") ] ] , HH.div [ HP.classes [ HH.ClassName "rt-block-actions" ] ] + ( [ Icons.iconButtonDisabled "Apply definition" Check (not canApply) (actions.applyBlock block.id) - , Icons.dangerButton (if confirming then "Confirm delete block" else "Delete block") - (if confirming then Check else Delete) - (actions.deleteBlock block.id) ] + <> confirmDeleteButtons "block" confirming (actions.deleteBlock block.id) actions.cancelConfirm + ) , case result.error of Just err -> HH.div [ HP.classes [ HH.ClassName "rt-block-error" ] ] [ HH.text err ] @@ -180,6 +182,21 @@ cascadeEntry entry = , HH.code_ [ HH.text entry.code ] ] +confirmDeleteButtons + :: forall action slots m + . String + -> Boolean + -> (MouseEvent -> action) + -> (MouseEvent -> action) + -> Array (HH.ComponentHTML action slots m) +confirmDeleteButtons label confirming confirmAction cancelAction = + if confirming then + [ Icons.dangerButton ("Confirm delete " <> label) Check confirmAction + , Icons.cancelButton ("Cancel delete " <> label) cancelAction + ] + else + [ Icons.dangerButton ("Delete " <> label) Delete confirmAction ] + currentToolbox :: App -> Maybe Toolbox currentToolbox app = case app.currentToolboxId of diff --git a/frontend/src/Riptide/View/Icons.purs b/frontend/src/Riptide/View/Icons.purs index 1007dad..3462d52 100644 --- a/frontend/src/Riptide/View/Icons.purs +++ b/frontend/src/Riptide/View/Icons.purs @@ -4,6 +4,7 @@ module Riptide.View.Icons , iconButton , iconButtonDisabled , iconButtonWithClasses + , cancelButton , dangerButton ) where @@ -14,11 +15,13 @@ import Halogen.HTML as HH import Halogen.HTML.Core (Namespace(..)) import Halogen.HTML.Events as HE import Halogen.HTML.Properties as HP +import Web.UIEvent.MouseEvent (MouseEvent) data Icon = Add | ArrowLeft | ArrowRight + | Cancel | Check | Copy | Delete @@ -45,19 +48,27 @@ iconButtonWithClasses :: forall action slots m. String -> Icon -> Array HH.Class iconButtonWithClasses title glyph classes action = button title glyph (Array.cons (HH.ClassName "rt-icon-button") classes) false action -dangerButton :: forall action slots m. String -> Icon -> action -> HH.ComponentHTML action slots m +cancelButton :: forall action slots m. String -> (MouseEvent -> action) -> HH.ComponentHTML action slots m +cancelButton title action = + buttonWithClick title Cancel [ HH.ClassName "rt-icon-button", HH.ClassName "rt-cancel" ] false action + +dangerButton :: forall action slots m. String -> Icon -> (MouseEvent -> action) -> HH.ComponentHTML action slots m dangerButton title glyph action = - button title glyph [ HH.ClassName "rt-icon-button", HH.ClassName "rt-danger" ] false action + buttonWithClick title glyph [ HH.ClassName "rt-icon-button", HH.ClassName "rt-danger" ] false action button :: forall action slots m. String -> Icon -> Array HH.ClassName -> Boolean -> action -> HH.ComponentHTML action slots m button title glyph classes disabled action = + buttonWithClick title glyph classes disabled \_ -> action + +buttonWithClick :: forall action slots m. String -> Icon -> Array HH.ClassName -> Boolean -> (MouseEvent -> action) -> HH.ComponentHTML action slots m +buttonWithClick title glyph classes disabled action = HH.button [ HP.type_ HP.ButtonButton , HP.title title , HP.attr (HH.AttrName "aria-label") title , HP.classes classes , HP.disabled disabled - , HE.onClick \_ -> action + , HE.onClick action ] [ icon glyph ] @@ -72,6 +83,10 @@ icon glyph = [ polyline "15 18 9 12 15 6" ] ArrowRight -> [ polyline "9 18 15 12 9 6" ] + Cancel -> + [ line "6" "6" "18" "18" + , line "18" "6" "6" "18" + ] Check -> [ polyline "5 13 9 17 19 7" ] Copy -> diff --git a/frontend/src/Riptide/View/Shell.purs b/frontend/src/Riptide/View/Shell.purs index f4feace..59fb683 100644 --- a/frontend/src/Riptide/View/Shell.purs +++ b/frontend/src/Riptide/View/Shell.purs @@ -14,6 +14,7 @@ import Riptide.Model (App, ConnectionState(..), Page(..), Song, Toolbox, connect import Riptide.Validation (authoritativeValidation) import Riptide.View.Icons (Icon(..)) import Riptide.View.Icons as Icons +import Web.UIEvent.KeyboardEvent (KeyboardEvent) type ShellActions action = { goSong :: action @@ -26,6 +27,8 @@ type ShellActions action = , importSong :: action , exportToolbox :: action , importToolbox :: action + , cancelConfirm :: action + , keyDown :: KeyboardEvent -> action } render @@ -35,7 +38,12 @@ render -> HH.ComponentHTML action slots m -> HH.ComponentHTML action slots m render actions app child = - HH.main [ HP.classes [ HH.ClassName "rt-shell" ] ] + HH.main + [ HP.classes [ HH.ClassName "rt-shell" ] + , HP.tabIndex 0 + , HE.onClick \_ -> actions.cancelConfirm + , HE.onKeyDown actions.keyDown + ] [ HH.header [ HP.classes [ HH.ClassName "rt-topbar" ] ] [ HH.div [ HP.classes [ HH.ClassName "rt-brand" ] ] [ HH.text "riptide" ] , HH.nav [ HP.classes [ HH.ClassName "rt-tabs" ] ] diff --git a/frontend/src/Riptide/View/Song.purs b/frontend/src/Riptide/View/Song.purs index b8a8933..39e1ce4 100644 --- a/frontend/src/Riptide/View/Song.purs +++ b/frontend/src/Riptide/View/Song.purs @@ -17,23 +17,25 @@ import Riptide.View.Icons (Icon(..)) import Riptide.View.Icons as Icons import Riptide.View.Score as Score import Web.HTML.Event.DragEvent (DragEvent) +import Web.UIEvent.MouseEvent (MouseEvent) type SongActions action = { newSong :: action , openSong :: SongId -> action , renameSong :: SongId -> String -> action , duplicateSong :: SongId -> action - , deleteSong :: SongId -> action + , deleteSong :: SongId -> MouseEvent -> action + , cancelConfirm :: MouseEvent -> action , renameTrack :: TrackId -> String -> action , setCtrl :: TrackId -> ControlKey -> String -> action , stopTrack :: TrackId -> action , addTrack :: action - , deleteTrack :: TrackId -> action + , deleteTrack :: TrackId -> MouseEvent -> action , addCell :: TrackId -> action , selectCell :: TrackId -> CellId -> action , toggleCell :: TrackId -> CellId -> action , editCode :: TrackId -> CellId -> String -> action - , deleteCell :: TrackId -> CellId -> action + , deleteCell :: TrackId -> CellId -> MouseEvent -> action , startEdit :: String -> String -> action , stopEdit :: action , focusCell :: CellId -> action @@ -101,13 +103,13 @@ songRow actions app song = , HH.small_ [ HH.text (show (Array.length song.tracks) <> " tracks") ] ] , HH.div [ HP.classes [ HH.ClassName "rt-row-actions" ] ] + ( [ Icons.iconButton "Open song" Eye (actions.openSong song.id) , Icons.iconButton "Rename song" Edit (actions.startEdit "song" song.id) , Icons.iconButton "Duplicate song" Copy (actions.duplicateSong song.id) - , Icons.dangerButton (if confirming then "Confirm delete song" else "Delete song") - (if confirming then Check else Delete) - (actions.deleteSong song.id) ] + <> confirmDeleteButtons "song" confirming (actions.deleteSong song.id) actions.cancelConfirm + ) ] songShell :: forall action slots m. SongActions action -> App -> Song -> Array (HH.ComponentHTML action slots m) @@ -166,11 +168,11 @@ trackRow actions app track = , HE.onValueInput (actions.renameTrack track.id) ] , HH.div [ HP.classes [ HH.ClassName "rt-track-tools" ] ] + ( [ Icons.iconButton "Stop track" Stop (actions.stopTrack track.id) - , Icons.dangerButton (if confirming then "Confirm delete track" else "Delete track") - (if confirming then Check else Delete) - (actions.deleteTrack track.id) ] + <> confirmDeleteButtons "track" confirming (actions.deleteTrack track.id) actions.cancelConfirm + ) , HH.div [ HP.classes [ HH.ClassName "rt-ctrls" ] ] [ ctrlSlider actions track Vol "Vol" track.vol , ctrlSlider actions track Flt "Flt" track.flt @@ -242,14 +244,14 @@ cellTile actions app track cell = , 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) - , Icons.dangerButton (if confirming then "Confirm delete cell" else "Delete cell") - (if confirming then Check else Delete) - (actions.deleteCell 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 ] @@ -276,6 +278,21 @@ addCellTile actions app trackId = ] [ Icons.icon Add ] +confirmDeleteButtons + :: forall action slots m + . String + -> Boolean + -> (MouseEvent -> action) + -> (MouseEvent -> action) + -> Array (HH.ComponentHTML action slots m) +confirmDeleteButtons label confirming confirmAction cancelAction = + if confirming then + [ Icons.dangerButton ("Confirm delete " <> label) Check confirmAction + , Icons.cancelButton ("Cancel delete " <> label) cancelAction + ] + else + [ Icons.dangerButton ("Delete " <> label) Delete confirmAction ] + trackClasses :: App -> DropTarget -> Array HH.ClassName trackClasses app target = [ HH.ClassName "rt-track" diff --git a/frontend/test/Main.purs b/frontend/test/Main.purs index e9c051c..33df004 100644 --- a/frontend/test/Main.purs +++ b/frontend/test/Main.purs @@ -213,6 +213,19 @@ main = map (_.id) (trackById "t1" deleted).cells `shouldEqual` [ "c1" ] map (_.id) (currentTracks deletedTrack) `shouldEqual` [ "t1", "t3" ] + it "cancels an armed destructive action without deleting the target" do + let + armed = Reducer.removeCell "t1" "c2" appWithSong + cancelled = Reducer.cancelConfirm armed + rearmed = Reducer.removeCell "t1" "c2" cancelled + deleted = Reducer.removeCell "t1" "c2" rearmed + + armed.confirm `shouldEqual` Just "cell:c2" + cancelled.confirm `shouldEqual` Nothing + map (_.id) (trackById "t1" cancelled).cells `shouldEqual` [ "c1", "c2" ] + rearmed.confirm `shouldEqual` Just "cell:c2" + map (_.id) (trackById "t1" deleted).cells `shouldEqual` [ "c1" ] + it "adds tracks and cells with explicit ids and normalized scores" do let app = Reducer.addCell "t1" "c7" (Reducer.addTrack "t4" appWithSong) diff --git a/gate.sh b/gate.sh index 4b56b59..2e0a3ec 100755 --- a/gate.sh +++ b/gate.sh @@ -240,21 +240,38 @@ async function assertFrontendInteractions(cdp) { for (const button of buttons) { const svg = button.querySelector("svg"); const svgBox = svg?.getBoundingClientRect(); + const buttonBox = button.getBoundingClientRect(); + const style = getComputedStyle(button); const visibleGraphic = [...(svg?.querySelectorAll(graphicSelector) || [])].some(isVisibleGraphic); if (!svg || !svgBox || svgBox.width === 0 || svgBox.height === 0 || !visibleGraphic) { failures.push(button.getAttribute("aria-label") || button.title || button.className || "unknown icon button"); + continue; + } + const dx = Math.abs((svgBox.left + svgBox.width / 2) - (buttonBox.left + buttonBox.width / 2)); + const dy = Math.abs((svgBox.top + svgBox.height / 2) - (buttonBox.top + buttonBox.height / 2)); + if (!["flex", "inline-flex"].includes(style.display) || dx > 1 || dy > 1) { + failures.push((button.getAttribute("aria-label") || button.title || button.className || "unknown icon button") + " not centered"); } } if (failures.length > 0) { - throw new Error("icon buttons without visible SVG glyphs: " + failures.join(", ")); + throw new Error("icon button glyph failures: " + failures.join(", ")); } - for (const selector of [".rt-cell-grip", ".rt-cell-select"]) { + for (const selector of [".rt-cell-grip", ".rt-cell-select", ".rt-cell-add"]) { const button = document.querySelector(selector); + const svg = button?.querySelector("svg"); const glyph = button?.querySelector(graphicSelector); if (!button || !glyph || !isVisibleGraphic(glyph)) { throw new Error(selector + " does not contain a visible icon glyph"); } + const style = getComputedStyle(button); + const buttonBox = button.getBoundingClientRect(); + const svgBox = svg.getBoundingClientRect(); + const dx = Math.abs((svgBox.left + svgBox.width / 2) - (buttonBox.left + buttonBox.width / 2)); + const dy = Math.abs((svgBox.top + svgBox.height / 2) - (buttonBox.top + buttonBox.height / 2)); + if (!["flex", "inline-flex"].includes(style.display) || dx > 1 || dy > 1) { + throw new Error(selector + " icon glyph is not centered"); + } } })()`); @@ -273,13 +290,48 @@ async function assertFrontendInteractions(cdp) { 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 evaluateOrThrow(cdp, `(() => { - const button = document.querySelector(".rt-danger[title^='Delete ']"); - if (!button) throw new Error("missing delete danger button"); + const cell = [...document.querySelectorAll(".rt-cell")] + .find((candidate) => (candidate.querySelector("textarea")?.value || "").includes('s "bd*2 sn:3"')); + const button = cell?.querySelector(".rt-danger[title='Delete cell']"); + if (!button) throw new Error("missing delete danger button for target seed cell"); const before = button.title; button.click(); return before; })()`); - await waitForCondition(cdp, `(() => Boolean(document.querySelector(".rt-danger[title^='Confirm delete ']")))()`, 2000, "delete danger button did not arm for confirmation"); + await waitForCondition(cdp, `(() => Boolean(document.querySelector(".rt-danger[title='Confirm delete cell']")))()`, 2000, "delete danger button did not arm for confirmation"); + + await evaluateOrThrow(cdp, `(() => { + const confirmButton = document.querySelector(".rt-danger[title='Confirm delete cell']"); + if (!confirmButton) throw new Error("missing armed confirm delete cell button"); + const cancelButton = [...document.querySelectorAll("button")] + .find((button) => button.title === "Cancel delete cell" || button.getAttribute("aria-label") === "Cancel delete cell"); + if (!cancelButton) throw new Error("missing explicit cancel delete cell button"); + cancelButton.click(); + })()`); + await waitForCondition(cdp, `(() => { + return Boolean(document.querySelector(".rt-danger[title='Delete cell']")) + && !document.querySelector(".rt-danger[title^='Confirm delete ']") + && [...document.querySelectorAll(".rt-cell")].some((cell) => (cell.querySelector("textarea")?.value || "").includes('s "bd*2 sn:3"')); + })()`, 2000, "cancel delete did not clear confirmation while preserving the cell"); + + await evaluateOrThrow(cdp, `(() => { + const cell = [...document.querySelectorAll(".rt-cell")] + .find((candidate) => (candidate.querySelector("textarea")?.value || "").includes('s "bd*2 sn:3"')); + const button = cell?.querySelector(".rt-danger[title='Delete cell']"); + if (!button) throw new Error("missing target seed cell delete button after cancel"); + button.click(); + })()`); + await waitForCondition(cdp, `(() => Boolean(document.querySelector(".rt-danger[title='Confirm delete cell']")))()`, 2000, "delete cell did not re-arm after cancel"); + await evaluateOrThrow(cdp, `(() => { + const cell = [...document.querySelectorAll(".rt-cell")] + .find((candidate) => (candidate.querySelector("textarea")?.value || "").includes('s "bd*2 sn:3"')); + const button = cell?.querySelector(".rt-danger[title='Confirm delete cell']"); + if (!button) throw new Error("missing target seed cell confirm delete button"); + button.click(); + })()`); + await waitForCondition(cdp, `(() => { + return ![...document.querySelectorAll(".rt-cell")].some((cell) => (cell.querySelector("textarea")?.value || "").includes('s "bd*2 sn:3"')); + })()`, 2000, "second confirm delete click did not remove the cell"); } async function evaluateOrThrow(cdp, expression) { diff --git a/specs/034-delete-cancel/tasks.md b/specs/034-delete-cancel/tasks.md index 414fa55..cf6ed47 100644 --- a/specs/034-delete-cancel/tasks.md +++ b/specs/034-delete-cancel/tasks.md @@ -1,11 +1,11 @@ # Tasks ## Slice 1 - Delete cancel and icon centering -- [ ] T034-S1 Add reducer disarm coverage and implementation. -- [ ] T034-S1 Wire cancel action through all delete views and the top-level app interactions. -- [ ] T034-S1 Center icon glyphs in all button boxes without regressing click behavior. -- [ ] T034-S1 Extend browser smoke for arm/cancel, arm/confirm, and practical icon centering checks. -- [ ] T034-S1 Run `./gate.sh` and commit one bisect-safe changeset. +- [X] T034-S1 Add reducer disarm coverage and implementation. +- [X] T034-S1 Wire cancel action through all delete views and the top-level app interactions. +- [X] T034-S1 Center icon glyphs in all button boxes without regressing click behavior. +- [X] T034-S1 Extend browser smoke for arm/cancel, arm/confirm, and practical icon centering checks. +- [X] T034-S1 Run `./gate.sh` and commit one bisect-safe changeset. ## Slice 2 - Cell control cleanup - [ ] T034-S2 Replace the cell eye select button with per-track radio groups wired to `selectCell`. From bd1b4b1867b8a8d18d88dd2fa47997c74f22c5cf Mon Sep 17 00:00:00 2001 From: Paolo Veronelli Date: Sat, 4 Jul 2026 13:23:55 +0100 Subject: [PATCH 4/4] fix(frontend): clean up cell controls Tasks: T034-S2 --- frontend/dist/index.html | 17 +++++++ frontend/src/Riptide/View/Icons.purs | 10 ++-- frontend/src/Riptide/View/Song.purs | 8 +-- gate.sh | 75 +++++++++++++++++++++++++++- specs/034-delete-cancel/tasks.md | 10 ++-- 5 files changed, 107 insertions(+), 13 deletions(-) diff --git a/frontend/dist/index.html b/frontend/dist/index.html index c5daa82..632e9cf 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -616,6 +616,9 @@ .rt-drag-handle { width: 28px; min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; padding: 0; cursor: grab; color: oklch(0.5 0.01 260); @@ -627,6 +630,11 @@ height: 15px; } + .rt-drag-handle .rt-icon circle { + fill: currentColor; + stroke: none; + } + .rt-cell-grip, .rt-cell-select { width: 28px; @@ -653,8 +661,17 @@ } .rt-cell-select { + appearance: auto; + width: 16px; + min-width: 16px; + height: 16px; + min-height: 16px; + margin: 0 6px; + padding: 0; + border-radius: 50%; color: oklch(0.68 0.03 200); background: transparent; + cursor: pointer; } .rt-drag-handle:active { diff --git a/frontend/src/Riptide/View/Icons.purs b/frontend/src/Riptide/View/Icons.purs index 3462d52..5ef931a 100644 --- a/frontend/src/Riptide/View/Icons.purs +++ b/frontend/src/Riptide/View/Icons.purs @@ -8,8 +8,6 @@ module Riptide.View.Icons , dangerButton ) where -import Prelude - import Data.Array as Array import Halogen.HTML as HH import Halogen.HTML.Core (Namespace(..)) @@ -114,8 +112,12 @@ icon glyph = , circle "12" "12" "3" ] Grip -> - [ line "9" "5" "9" "19" - , line "15" "5" "15" "19" + [ circle "9" "6" "1.2" + , circle "15" "6" "1.2" + , circle "9" "12" "1.2" + , circle "15" "12" "1.2" + , circle "9" "18" "1.2" + , circle "15" "18" "1.2" ] Hush -> [ path "M4 10v4h4l5 4V6l-5 4H4Z" diff --git a/frontend/src/Riptide/View/Song.purs b/frontend/src/Riptide/View/Song.purs index 39e1ce4..9bfd1f5 100644 --- a/frontend/src/Riptide/View/Song.purs +++ b/frontend/src/Riptide/View/Song.purs @@ -225,14 +225,16 @@ cellTile actions app track cell = , HE.onDragEnd \_ -> actions.endDrag ] [ Icons.icon Grip ] - , HH.button - [ HP.type_ HP.ButtonButton + , HH.input + [ HP.type_ HP.InputRadio + , HP.name ("cell-select-" <> track.id) + , HP.value cell.id + , HP.checked selected , HP.title (if selected then "Selected cell" else "Select cell") , HP.attr (HH.AttrName "aria-label") (if selected then "Selected cell" else "Select cell") , HP.classes [ HH.ClassName "rt-cell-select" ] , HE.onClick \_ -> actions.selectCell track.id cell.id ] - [ Icons.icon Eye ] , HH.span [ HP.classes [ HH.ClassName "rt-cell-state" ] ] [ HH.text (cellStateLabel result active selected editing) ] ] , HH.textarea diff --git a/gate.sh b/gate.sh index 2e0a3ec..674ec44 100755 --- a/gate.sh +++ b/gate.sh @@ -257,7 +257,7 @@ async function assertFrontendInteractions(cdp) { throw new Error("icon button glyph failures: " + failures.join(", ")); } - for (const selector of [".rt-cell-grip", ".rt-cell-select", ".rt-cell-add"]) { + for (const selector of [".rt-cell-grip", ".rt-cell-add"]) { const button = document.querySelector(selector); const svg = button?.querySelector("svg"); const glyph = button?.querySelector(graphicSelector); @@ -275,6 +275,79 @@ async function assertFrontendInteractions(cdp) { } })()`); + await evaluateOrThrow(cdp, `(() => { + const failures = []; + const tracks = [...document.querySelectorAll(".rt-track")]; + 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")]; + if (radios.length !== cells.length) { + failures.push("track has " + radios.length + " radio selectors for " + cells.length + " cells"); + continue; + } + if (new Set(radios.map((radio) => radio.name)).size !== 1) { + failures.push("track radios are not one named group"); + } + if (radios.filter((radio) => radio.checked).length !== 1) { + failures.push("track radio group does not have exactly one selected cell"); + } + for (const radio of radios) { + const box = radio.getBoundingClientRect(); + const style = getComputedStyle(radio); + if (box.width < 12 || box.height < 12 || style.display === "none" || style.visibility === "hidden" || style.opacity === "0") { + failures.push("cell selector radio is not visibly native-sized"); + } + const headBox = radio.closest(".rt-cell-head")?.getBoundingClientRect(); + if (!headBox || Math.abs((box.top + box.height / 2) - (headBox.top + headBox.height / 2)) > 1.5) { + failures.push("cell selector radio is not vertically centered in the header"); + } + } + } + + const firstTrackRadios = [...document.querySelectorAll(".rt-track:first-of-type .rt-cell-head input.rt-cell-select[type='radio']")]; + if (firstTrackRadios.length >= 2) { + const next = firstTrackRadios.find((radio) => !radio.checked) || firstTrackRadios[1]; + const groupName = next.name; + next.click(); + const checked = [...document.querySelectorAll("input.rt-cell-select[type='radio'][name='" + CSS.escape(groupName) + "']")] + .filter((radio) => radio.checked); + if (checked.length !== 1 || checked[0] !== next) { + failures.push("clicking a cell radio did not update the mutually exclusive checked state"); + } + } else { + failures.push("first track does not have enough radios for selection smoke"); + } + + const oldSelectButtons = [...document.querySelectorAll(".rt-cell-head button.rt-cell-select, .rt-cell-head button[title='Select cell'], .rt-cell-head button[aria-label='Select cell'], .rt-cell-head button[title='Selected cell'], .rt-cell-head button[aria-label='Selected cell']")]; + if (oldSelectButtons.length > 0) { + failures.push("old eye select button still exists in cell headers"); + } + + for (const grip of document.querySelectorAll(".rt-drag-handle")) { + const circles = [...grip.querySelectorAll("svg circle")]; + const lines = [...grip.querySelectorAll("svg line")]; + const linePoints = lines.map((line) => [line.getAttribute("x1"), line.getAttribute("y1"), line.getAttribute("x2"), line.getAttribute("y2")].join(",")); + const hasPauseLikeGlyph = linePoints.includes("9,5,9,19") && linePoints.includes("15,5,15,19"); + if (circles.length !== 6) { + failures.push((grip.title || "drag handle") + " does not use a six-dot grip glyph"); + } + if (hasPauseLikeGlyph) { + failures.push((grip.title || "drag handle") + " still uses the pause-like two-line glyph"); + } + const gripBox = grip.getBoundingClientRect(); + const svgBox = grip.querySelector("svg")?.getBoundingClientRect(); + if (!svgBox || Math.abs((svgBox.left + svgBox.width / 2) - (gripBox.left + gripBox.width / 2)) > 1 || Math.abs((svgBox.top + svgBox.height / 2) - (gripBox.top + gripBox.height / 2)) > 1) { + failures.push((grip.title || "drag handle") + " glyph is not centered"); + } + } + + if (failures.length > 0) { + throw new Error("cell control cleanup failures: " + [...new Set(failures)].join("; ")); + } + })()`); + 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)'); diff --git a/specs/034-delete-cancel/tasks.md b/specs/034-delete-cancel/tasks.md index cf6ed47..140de2a 100644 --- a/specs/034-delete-cancel/tasks.md +++ b/specs/034-delete-cancel/tasks.md @@ -8,8 +8,8 @@ - [X] T034-S1 Run `./gate.sh` and commit one bisect-safe changeset. ## Slice 2 - Cell control cleanup -- [ ] T034-S2 Replace the cell eye select button with per-track radio groups wired to `selectCell`. -- [ ] T034-S2 Replace the pause-like grip glyph with a subtle six-dot drag handle. -- [ ] T034-S2 Adjust cell header CSS to read cleanly as grip, radio, state badge. -- [ ] T034-S2 Extend browser smoke for radio mutual exclusion, grip shape, centered glyphs, and clean render. -- [ ] T034-S2 Run `./gate.sh` and commit one bisect-safe changeset. +- [X] T034-S2 Replace the cell eye select button with per-track radio groups wired to `selectCell`. +- [X] T034-S2 Replace the pause-like grip glyph with a subtle six-dot drag handle. +- [X] T034-S2 Adjust cell header CSS to read cleanly as grip, radio, state badge. +- [X] T034-S2 Extend browser smoke for radio mutual exclusion, grip shape, centered glyphs, and clean render. +- [X] T034-S2 Run `./gate.sh` and commit one bisect-safe changeset.