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.