diff --git a/frontend/dist/index.html b/frontend/dist/index.html
index b038e67..632e9cf 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;
@@ -615,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);
@@ -626,13 +630,20 @@
height: 15px;
}
+ .rt-drag-handle .rt-icon circle {
+ fill: currentColor;
+ stroke: none;
+ }
+
.rt-cell-grip,
.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;
}
@@ -650,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 {
@@ -681,8 +701,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..5ef931a 100644
--- a/frontend/src/Riptide/View/Icons.purs
+++ b/frontend/src/Riptide/View/Icons.purs
@@ -4,21 +4,22 @@ module Riptide.View.Icons
, iconButton
, iconButtonDisabled
, iconButtonWithClasses
+ , cancelButton
, dangerButton
) where
-import Prelude
-
import Data.Array as Array
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 +46,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 +81,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 ->
@@ -99,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/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..9bfd1f5 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
@@ -223,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
@@ -242,14 +246,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 +280,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..674ec44 100755
--- a/gate.sh
+++ b/gate.sh
@@ -240,21 +240,111 @@ 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-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");
+ }
+ }
+ })()`);
+
+ 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("; "));
}
})()`);
@@ -273,13 +363,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/plan.md b/specs/034-delete-cancel/plan.md
new file mode 100644
index 0000000..22f4a53
--- /dev/null
+++ b/specs/034-delete-cancel/plan.md
@@ -0,0 +1,36 @@
+# 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 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:
+ - 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.
+
+## 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 .#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, radio, and grip assertions pass.
diff --git a/specs/034-delete-cancel/spec.md b/specs/034-delete-cancel/spec.md
new file mode 100644
index 0000000..f7e1026
--- /dev/null
+++ b/specs/034-delete-cancel/spec.md
@@ -0,0 +1,29 @@
+# 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/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.
+- 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, 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
new file mode 100644
index 0000000..140de2a
--- /dev/null
+++ b/specs/034-delete-cancel/tasks.md
@@ -0,0 +1,15 @@
+# Tasks
+
+## Slice 1 - Delete cancel and icon centering
+- [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
+- [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.