Skip to content

Commit 43ff8e8

Browse files
hyperpolymathclaude
andcommitted
feat(frontend): undo/redo, auto-save, dark mode — TSDM MUSTs 3-5
Undo/redo system (UX Manifesto non-negotiable): - Snapshot-based undo stack (components + connections) with 50-depth cap - Redo stack clears on new mutations, preserves on undo/redo chains - pushUndo called before every mutating action (Add/Remove Component, Add/Remove Connection, UpdateComponentConfig) - Ctrl+Z / Ctrl+Y keyboard shortcuts with preventDefault - Visible Undo/Redo buttons in nav bar with disabled state Auto-save (UX Manifesto 30-second rule): - 30-second interval timer checks model.isDirty flag - Saves via ApiClient.saveStack, dispatches MarkClean on success - Visual "Saved" / "Unsaved" indicator in nav bar - isDirty flag set by all mutating actions, cleared on save Dark mode (STATUS.md + ACCESSIBILITY.md): - System detection via prefers-color-scheme matchMedia - localStorage persistence (stapeln_theme key) - HTML dark class sync for Tailwind dark: variants - StackView fixed to receive isDark prop (was hardcoded false) Clean build: 50 modules, 0 errors, 0 warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3340e79 commit 43ff8e8

11 files changed

Lines changed: 743 additions & 140 deletions

File tree

frontend/src/App.res

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,29 @@ type appState = {
1818
isDark: bool,
1919
}
2020

21+
// Detect OS-level dark mode preference via matchMedia.
22+
let systemPrefersDark = (): bool => {
23+
%raw(`window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches`)
24+
}
25+
26+
// Helpers to toggle the "dark" class on <html> for Tailwind dark mode.
27+
let addDarkClass: unit => unit = %raw(`function() { document.documentElement.classList.add("dark") }`)
28+
let removeDarkClass: unit => unit = %raw(`function() { document.documentElement.classList.remove("dark") }`)
29+
30+
// Resolve the initial isDark value: localStorage override > system preference.
31+
let resolveInitialDark = (): bool => {
32+
switch WebAPI.getItem("stapeln_theme")->Nullable.toOption {
33+
| Some("light") => false
34+
| Some("dark") => true
35+
| Some(_) | None => systemPrefersDark()
36+
}
37+
}
38+
2139
let initialAppState = {
2240
currentPage: AppRouter.getCurrentRoute(),
2341
model: initialModel,
2442
pipelineDesigner: PipelineModel.initialState(),
25-
isDark: true,
43+
isDark: resolveInitialDark(),
2644
}
2745

2846
// Serialise the current stack model to a JSON string suitable for the API.
@@ -246,6 +264,23 @@ let make = () => {
246264
)
247265
}
248266

267+
| AutoSaveTick => {
268+
// Only save if model is dirty
269+
if state.model.isDirty {
270+
let body = serializeStack(state.model)
271+
ignore(
272+
ApiClient.saveStack(body)
273+
->Promise.then(result => {
274+
switch result {
275+
| Ok(_) => dispatch(MarkClean)
276+
| Error(_) => () // Silent fail — will retry next tick
277+
}
278+
Promise.resolve()
279+
}),
280+
)
281+
}
282+
}
283+
249284
| _ => ()
250285
}
251286
}
@@ -263,6 +298,48 @@ let make = () => {
263298
None
264299
})
265300

301+
// Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Y / Ctrl+Shift+Z (redo)
302+
React.useEffect0(() => {
303+
let _keyHandler = (e: {..}) => {
304+
let key: string = e["key"]
305+
let ctrlKey: bool = e["ctrlKey"]
306+
let metaKey: bool = e["metaKey"]
307+
let shiftKey: bool = e["shiftKey"]
308+
let mod_ = ctrlKey || metaKey
309+
if mod_ && !shiftKey && key === "z" {
310+
e["preventDefault"](.)
311+
dispatch(Undo)
312+
} else if mod_ && (key === "y" || (shiftKey && (key === "z" || key === "Z"))) {
313+
e["preventDefault"](.)
314+
dispatch(Redo)
315+
}
316+
}
317+
let _: unit = %raw(`document.addEventListener("keydown", _keyHandler)`)
318+
Some(() => {
319+
let _: unit = %raw(`document.removeEventListener("keydown", _keyHandler)`)
320+
})
321+
})
322+
323+
// Auto-save timer: check every 30 seconds if model is dirty
324+
React.useEffect0(() => {
325+
let _intervalId = %raw(`setInterval(() => { dispatch(Msg.AutoSaveTick) }, 30000)`)
326+
Some(() => {
327+
let _: unit = %raw(`clearInterval(_intervalId)`)
328+
})
329+
})
330+
331+
// Persist theme to localStorage and sync "dark" class on <html> for Tailwind.
332+
React.useEffect1(() => {
333+
WebAPI.setItem("stapeln_theme", state.isDark ? "dark" : "light")
334+
// Sync document.documentElement.classList for Tailwind dark: variants
335+
if state.isDark {
336+
addDarkClass()
337+
} else {
338+
removeDarkClass()
339+
}
340+
None
341+
}, [state.isDark])
342+
266343
<ErrorBoundary>
267344
<div className="app">
268345
<nav className="nav-tabs">
@@ -322,6 +399,45 @@ let make = () => {
322399
</button>
323400

324401
<div className="nav-actions">
402+
// Undo/Redo buttons
403+
<button
404+
className="action-btn"
405+
onClick={_ => dispatch(Undo)}
406+
disabled={!Model.canUndo(state.model)}
407+
title="Undo (Ctrl+Z)"
408+
style={Sx.make(
409+
~opacity=Model.canUndo(state.model) ? "1" : "0.4",
410+
~cursor=Model.canUndo(state.model) ? "pointer" : "default",
411+
(),
412+
)}
413+
>
414+
{"Undo"->React.string}
415+
</button>
416+
<button
417+
className="action-btn"
418+
onClick={_ => dispatch(Redo)}
419+
disabled={!Model.canRedo(state.model)}
420+
title="Redo (Ctrl+Y)"
421+
style={Sx.make(
422+
~opacity=Model.canRedo(state.model) ? "1" : "0.4",
423+
~cursor=Model.canRedo(state.model) ? "pointer" : "default",
424+
(),
425+
)}
426+
>
427+
{"Redo"->React.string}
428+
</button>
429+
// Save status indicator
430+
<span
431+
style={Sx.make(
432+
~fontSize="0.8rem",
433+
~color=state.model.isDirty ? "#fbc02d" : "#66bb6a",
434+
~padding="0.5rem",
435+
(),
436+
)}
437+
ariaLive=#polite
438+
>
439+
{(state.model.isDirty ? "Unsaved" : "Saved")->React.string}
440+
</span>
325441
<button
326442
className="action-btn"
327443
onClick={_ => dispatch(TriggerImportDesign)}

frontend/src/App.res.js

Lines changed: 98 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/AppIntegrated.res

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ let make = () => {
277277
{switch state.currentRoute {
278278
| NetworkView =>
279279
TopologyView.view(state.model, state.isDark, stackMsg => dispatch(StackMsg(stackMsg)))
280-
| StackView => StackView.view(state.model)
280+
| StackView => StackView.view(state.model, ~isDark=state.isDark)
281281
| PipelineView =>
282282
<PipelineDesigner
283283
state={PipelineModel.initialState()}

frontend/src/AppIntegrated.res.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/Model.res

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ let defaultSettingsConfig: settingsConfig = {
5151
backendUrl: "/api",
5252
}
5353

54+
// Snapshot of undoable state (components + connections only)
55+
type snapshot = {
56+
components: array<component>,
57+
connections: array<connection>,
58+
}
59+
60+
// Maximum undo history depth
61+
let maxUndoDepth = 50
62+
5463
type rec model = {
5564
components: array<component>,
5665
connections: array<connection>,
@@ -69,6 +78,12 @@ type rec model = {
6978
settings: settingsConfig,
7079
// WebSocket state (optional — None means REST-only mode)
7180
wsState: Socket.connectionState,
81+
// Undo/redo history
82+
undoStack: array<snapshot>,
83+
redoStack: array<snapshot>,
84+
// Dirty tracking for auto-save
85+
isDirty: bool,
86+
lastSavedAt: option<float>, // Date.now() timestamp
7287
}
7388

7489
and validationResult = {
@@ -92,6 +107,10 @@ let initialModel = {
92107
currentStackId: None,
93108
settings: defaultSettingsConfig,
94109
wsState: Disconnected,
110+
undoStack: [],
111+
redoStack: [],
112+
isDirty: false,
113+
lastSavedAt: None,
95114
}
96115

97116
// Helper functions
@@ -115,6 +134,29 @@ let findComponent = (model: model, id: string): option<component> => {
115134
Array.getBy(model.components, c => c.id == id)
116135
}
117136

137+
// Take a snapshot of the current undoable state
138+
let takeSnapshot = (model: model): snapshot => {
139+
components: model.components,
140+
connections: model.connections,
141+
}
142+
143+
// Push a snapshot onto the undo stack (call BEFORE making the change)
144+
let pushUndo = (model: model): model => {
145+
let snap = takeSnapshot(model)
146+
let stack = Array.concat(model.undoStack, [snap])
147+
// Trim to max depth
148+
let trimmed = if Array.length(stack) > maxUndoDepth {
149+
Array.slice(stack, ~offset=Array.length(stack) - maxUndoDepth, ~len=maxUndoDepth)
150+
} else {
151+
stack
152+
}
153+
{...model, undoStack: trimmed, redoStack: [], isDirty: true}
154+
}
155+
156+
// Check if undo/redo are available
157+
let canUndo = (model: model): bool => Array.length(model.undoStack) > 0
158+
let canRedo = (model: model): bool => Array.length(model.redoStack) > 0
159+
118160
let componentTypeToString = (ct: componentType): string => {
119161
switch ct {
120162
| CerroTorre => "Cerro Torre"

0 commit comments

Comments
 (0)