@liberation-data/desk 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ /*
2
+ * desk — the stage and its windows. Every value reads a --desk-* token, so an
3
+ * app restyles the desk by redefining tokens, never by overriding selectors.
4
+ */
5
+ :root {
6
+ --desk-ground: #eef0f4;
7
+ --desk-surface: #ffffff;
8
+ --desk-titlebar: #f6f7f9;
9
+ --desk-ink: #1b1f27;
10
+ --desk-muted: #667085;
11
+ --desk-rule: #dde1e8;
12
+ --desk-accent: #5b57f5;
13
+ --desk-close: #ef6b63;
14
+ --desk-mode: #34c47a;
15
+ --desk-info: #34c47a;
16
+ --desk-radius: 12px;
17
+ --desk-gap: 12px;
18
+ --desk-padding: 12px;
19
+ --desk-shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 12px 32px rgb(16 24 40 / 10%);
20
+ --desk-shadow-floating: 0 2px 4px rgb(16 24 40 / 8%), 0 24px 60px rgb(16 24 40 / 22%);
21
+ --desk-font: system-ui, -apple-system, "Segoe UI", sans-serif;
22
+ --desk-font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
23
+
24
+ /* The spacing scale (HIG.md §2). Nothing between these values. */
25
+ --desk-space-1: 4px;
26
+ --desk-space-2: 8px;
27
+ --desk-space-3: 12px;
28
+ --desk-space-4: 16px;
29
+ --desk-space-5: 24px;
30
+ --desk-space-6: 32px;
31
+ --desk-space-7: 48px;
32
+
33
+ /* State, kept apart from the accent. */
34
+ --desk-ok: #2f9e68;
35
+ --desk-warn: #b7791f;
36
+ --desk-bad: #c6483f;
37
+
38
+ --desk-control-height: 28px;
39
+ --desk-control-height-small: 22px;
40
+ --desk-control-radius: 7px;
41
+ --desk-field-bg: #ffffff;
42
+ --desk-on-accent: #ffffff;
43
+ --desk-ease: cubic-bezier(.32, .72, 0, 1);
44
+ --desk-dock-size: 48px;
45
+ --desk-dock-glass: rgb(255 255 255 / 72%);
46
+ --desk-dock-icon: linear-gradient(160deg, #ffffff, #eceff5);
47
+ --desk-badge: #ef4444;
48
+ --desk-badge-ink: #ffffff;
49
+ /* Room the stage leaves at the bottom for an overlay dock. */
50
+ --desk-inset-bottom: 0px;
51
+ }
52
+
53
+ @media (prefers-color-scheme: dark) {
54
+ :root:not([data-theme="light"]) {
55
+ --desk-ground: #07090f;
56
+ --desk-surface: #101828;
57
+ --desk-titlebar: #0b111d;
58
+ --desk-ink: #e6e6e6;
59
+ --desk-muted: #99a1af;
60
+ --desk-rule: #1e2939;
61
+ --desk-shadow: 0 12px 32px rgb(0 0 0 / 45%);
62
+ --desk-shadow-floating: 0 30px 80px rgb(0 0 0 / 70%);
63
+ --desk-dock-glass: rgb(11 18 32 / 62%);
64
+ --desk-dock-icon: linear-gradient(160deg, #1a2338, #0e1526);
65
+ --desk-ok: #3ecf8e;
66
+ --desk-info: #3ecf8e;
67
+ --desk-warn: #fbbf24;
68
+ --desk-bad: #f87171;
69
+ --desk-field-bg: rgb(0 0 0 / 35%);
70
+ }
71
+ }
72
+
73
+ :root[data-theme="dark"] {
74
+ --desk-ground: #07090f;
75
+ --desk-surface: #101828;
76
+ --desk-titlebar: #0b111d;
77
+ --desk-ink: #e6e6e6;
78
+ --desk-muted: #99a1af;
79
+ --desk-rule: #1e2939;
80
+ --desk-shadow: 0 12px 32px rgb(0 0 0 / 45%);
81
+ --desk-shadow-floating: 0 30px 80px rgb(0 0 0 / 70%);
82
+ --desk-dock-glass: rgb(11 18 32 / 62%);
83
+ --desk-dock-icon: linear-gradient(160deg, #1a2338, #0e1526);
84
+ --desk-ok: #3ecf8e;
85
+ --desk-info: #3ecf8e;
86
+ --desk-warn: #fbbf24;
87
+ --desk-bad: #f87171;
88
+ --desk-field-bg: rgb(0 0 0 / 35%);
89
+ }
@@ -0,0 +1,320 @@
1
+ .desk-stage {
2
+ position: relative;
3
+ box-sizing: border-box;
4
+ height: 100%;
5
+ min-height: 0;
6
+ padding: var(--desk-padding);
7
+ padding-bottom: calc(var(--desk-padding) + var(--desk-inset-bottom));
8
+ display: grid;
9
+ grid-template: minmax(0, 1fr) / minmax(0, 1fr);
10
+ gap: var(--desk-gap);
11
+ overflow: hidden;
12
+ background: var(--desk-ground);
13
+ color: var(--desk-ink);
14
+ font-family: var(--desk-font);
15
+ }
16
+
17
+ .desk-window {
18
+ /* Filled windows fill the stage inside its padding, layered in the order they were used. */
19
+ position: absolute;
20
+ top: var(--desk-padding);
21
+ right: var(--desk-padding);
22
+ bottom: calc(var(--desk-padding) + var(--desk-inset-bottom));
23
+ left: var(--desk-padding);
24
+ box-sizing: border-box;
25
+ min-width: 0;
26
+ min-height: 0;
27
+ display: flex;
28
+ flex-direction: column;
29
+ background: var(--desk-surface);
30
+ border: 1px solid var(--desk-rule);
31
+ border-radius: var(--desk-radius);
32
+ box-shadow: var(--desk-shadow);
33
+ overflow: hidden;
34
+ transition: border-color .15s var(--desk-ease), box-shadow .15s var(--desk-ease);
35
+ }
36
+
37
+ .desk-window:focus {
38
+ /* The key window is already marked by its border; a ring on the whole window would be noise. */
39
+ outline: none;
40
+ }
41
+
42
+ .desk-window[data-focused] {
43
+ border-color: color-mix(in srgb, var(--desk-accent) 45%, var(--desk-rule));
44
+ }
45
+
46
+ .desk-window[data-hidden] {
47
+ display: none;
48
+ }
49
+
50
+ /* One window at a time: it fills the stage, and the zoom control is not offered. */
51
+ .desk-stage[data-layout="fullscreen"] .desk-window {
52
+ position: static;
53
+ inset: auto;
54
+ grid-area: 1 / 1;
55
+ }
56
+
57
+ .desk-window[data-mode="floating"] {
58
+ right: auto;
59
+ bottom: auto;
60
+ box-shadow: var(--desk-shadow-floating);
61
+ }
62
+
63
+ .desk-window[data-dragging] {
64
+ transition: none;
65
+ user-select: none;
66
+ }
67
+
68
+ .desk-titlebar {
69
+ flex: none;
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 10px;
73
+ padding: 8px 12px;
74
+ background: var(--desk-titlebar);
75
+ border-bottom: 1px solid var(--desk-rule);
76
+ user-select: none;
77
+ }
78
+
79
+ .desk-titlebar[data-draggable] {
80
+ cursor: grab;
81
+ touch-action: none;
82
+ }
83
+
84
+ .desk-window[data-dragging] .desk-titlebar {
85
+ cursor: grabbing;
86
+ }
87
+
88
+ .desk-controls {
89
+ display: flex;
90
+ gap: 7px;
91
+ }
92
+
93
+ .desk-control {
94
+ width: 12px;
95
+ height: 12px;
96
+ padding: 0;
97
+ border: 0;
98
+ border-radius: 50%;
99
+ cursor: pointer;
100
+ background: var(--desk-rule);
101
+ }
102
+
103
+ .desk-window[data-focused] .desk-control[data-control="close"],
104
+ .desk-control[data-control="close"]:hover {
105
+ background: var(--desk-close);
106
+ }
107
+
108
+ .desk-window[data-focused] .desk-control[data-control="mode"],
109
+ .desk-control[data-control="mode"]:hover {
110
+ background: var(--desk-mode);
111
+ }
112
+
113
+ .desk-control:focus-visible {
114
+ outline: 2px solid var(--desk-accent);
115
+ outline-offset: 2px;
116
+ }
117
+
118
+ .desk-title {
119
+ margin: 0;
120
+ min-width: 0;
121
+ overflow: hidden;
122
+ text-overflow: ellipsis;
123
+ white-space: nowrap;
124
+ font-size: 13px;
125
+ font-weight: 600;
126
+ }
127
+
128
+ .desk-window:not([data-focused]) .desk-title {
129
+ color: var(--desk-muted);
130
+ }
131
+
132
+ .desk-window-note {
133
+ margin: 0 0 0 auto;
134
+ min-width: 0;
135
+ /* The title is what the window IS; the note is what it is looking at. In a narrow window the note
136
+ gives up its room first, and is cut before the title is. */
137
+ flex: 0 999 auto;
138
+ overflow: hidden;
139
+ text-overflow: ellipsis;
140
+ white-space: nowrap;
141
+ font-size: 11.5px;
142
+ color: var(--desk-muted);
143
+ }
144
+
145
+ /* The note gives up the spare room; the (i) keeps its place at the end of the bar. */
146
+ .desk-window-note + .desk-window-info {
147
+ margin-left: var(--desk-space-2);
148
+ }
149
+
150
+ .desk-window-info {
151
+ margin-left: auto;
152
+ }
153
+
154
+ .desk-window-note ~ .desk-window-info {
155
+ margin-left: var(--desk-space-2);
156
+ }
157
+
158
+ .desk-window-info + .desk-window-actions,
159
+ .desk-window-note + .desk-window-actions {
160
+ margin-left: var(--desk-space-1);
161
+ }
162
+
163
+ .desk-window-actions {
164
+ margin-left: auto;
165
+ display: flex;
166
+ align-items: center;
167
+ gap: var(--desk-space-1);
168
+ }
169
+
170
+ .desk-body {
171
+ flex: 1;
172
+ min-height: 0;
173
+ overflow: auto;
174
+ }
175
+
176
+ .desk-window-status {
177
+ box-sizing: border-box;
178
+ height: 100%;
179
+ min-height: 120px;
180
+ display: flex;
181
+ flex-direction: column;
182
+ align-items: center;
183
+ justify-content: center;
184
+ gap: var(--desk-space-2);
185
+ padding: var(--desk-space-4);
186
+ text-align: center;
187
+ color: var(--desk-muted);
188
+ font-size: 13px;
189
+ }
190
+
191
+ .desk-window-status b {
192
+ color: var(--desk-ink);
193
+ font-weight: 600;
194
+ }
195
+
196
+ .desk-window-status-detail {
197
+ max-width: 40ch;
198
+ font-size: 12px;
199
+ }
200
+
201
+ /* Edges a window resizes from. Inside the window, so nothing clips them. */
202
+ .desk-edge {
203
+ position: absolute;
204
+ touch-action: none;
205
+ }
206
+
207
+ .desk-edge[data-edge="left"],
208
+ .desk-edge[data-edge="right"] {
209
+ top: 0;
210
+ bottom: 0;
211
+ width: 5px;
212
+ cursor: ew-resize;
213
+ }
214
+
215
+ .desk-edge[data-edge="left"] {
216
+ left: 0;
217
+ }
218
+
219
+ .desk-edge[data-edge="right"] {
220
+ right: 0;
221
+ }
222
+
223
+ .desk-edge[data-edge="bottom"] {
224
+ left: 0;
225
+ right: 0;
226
+ bottom: 0;
227
+ height: 5px;
228
+ cursor: ns-resize;
229
+ }
230
+
231
+ .desk-grip {
232
+ position: absolute;
233
+ right: 0;
234
+ bottom: 0;
235
+ width: 16px;
236
+ height: 16px;
237
+ cursor: nwse-resize;
238
+ touch-action: none;
239
+ }
240
+
241
+ /* Where a dragged window would land if it were dropped now. */
242
+ .desk-stage[data-snap]::after {
243
+ content: "";
244
+ position: absolute;
245
+ top: var(--desk-padding);
246
+ bottom: calc(var(--desk-padding) + var(--desk-inset-bottom));
247
+ width: calc(50% - var(--desk-padding) - var(--desk-gap) / 2);
248
+ border: 2px solid var(--desk-accent);
249
+ border-radius: var(--desk-radius);
250
+ background: color-mix(in srgb, var(--desk-accent) 14%, transparent);
251
+ pointer-events: none;
252
+ z-index: 5;
253
+ }
254
+
255
+ .desk-stage[data-snap="start"]::after {
256
+ left: var(--desk-padding);
257
+ }
258
+
259
+ .desk-stage[data-snap="end"]::after {
260
+ right: var(--desk-padding);
261
+ }
262
+
263
+ /* ── The inside of a window: controls that stay, content that scrolls ── */
264
+
265
+ .desk-pane {
266
+ display: flex;
267
+ flex-direction: column;
268
+ height: 100%;
269
+ min-height: 0;
270
+ }
271
+
272
+ .desk-pane-header,
273
+ .desk-pane-footer {
274
+ flex: none;
275
+ }
276
+
277
+ /* The only part that scrolls, so the toolbar never leaves with the content. */
278
+ .desk-pane-body {
279
+ flex: 1;
280
+ min-height: 0;
281
+ overflow: auto;
282
+ }
283
+
284
+ .desk-pane-body:focus-visible {
285
+ outline: 2px solid var(--desk-accent);
286
+ outline-offset: -2px;
287
+ }
288
+
289
+ /* A window whose whole content is a pane does not scroll itself: the pane's middle does. */
290
+ .desk-body:has(> .desk-pane) {
291
+ overflow: hidden;
292
+ }
293
+
294
+ .desk-toolbar {
295
+ display: flex;
296
+ align-items: center;
297
+ flex-wrap: wrap;
298
+ gap: var(--desk-space-2);
299
+ /* One centre line for everything in the row, whatever each control's own height is. */
300
+ min-height: calc(var(--desk-control-height) + var(--desk-space-2) * 2);
301
+ padding: var(--desk-space-2) var(--desk-space-3);
302
+ border-bottom: 1px solid var(--desk-rule);
303
+ }
304
+
305
+ .desk-toolbar > *,
306
+ .desk-toolbar-trailing > * {
307
+ flex: none;
308
+ }
309
+
310
+ .desk-toolbar .desk-grow {
311
+ flex: 1 1 140px;
312
+ min-width: 0;
313
+ }
314
+
315
+ .desk-toolbar-trailing {
316
+ margin-left: auto;
317
+ display: flex;
318
+ align-items: center;
319
+ gap: var(--desk-space-2);
320
+ }
@@ -2,8 +2,10 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
2
2
  import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react'
3
3
  import { addCommandHandler, addDeskCommands, DeskCommands, STAGE_ATTRIBUTE, WINDOW_ATTRIBUTE, windowElement } from '../core/commands.js'
4
4
  import { focusedId } from '../core/desk.js'
5
+ import type { Desk } from '../core/desk.js'
5
6
  import type { DeskWindow, Frame, WindowId } from '../core/types.js'
6
7
  import { arrangement } from './arrange.js'
8
+ import { InfoTip } from './infoTip.js'
7
9
  import { useDesk, useDeskState, WindowContext } from './context.js'
8
10
  import { WindowBoundary } from './windowBoundary.js'
9
11
  import type { WindowFailed, WindowLoading } from './windowBoundary.js'
@@ -19,6 +21,12 @@ export interface DesktopProps {
19
21
  * gives way to the actions and disappears before the title does when the window is narrow.
20
22
  */
21
23
  readonly note?: (id: WindowId) => ReactNode
24
+ /**
25
+ * What this window is, behind an (i) at the end of its title bar — the same place in every window,
26
+ * so a person who wonders what they are looking at always knows where to ask. Explanation only: a
27
+ * warning, or anything that changes what someone is about to do, belongs in the window itself.
28
+ */
29
+ readonly info?: (id: WindowId) => ReactNode
22
30
  /** Window-wide controls on the trailing side of the title bar. One or two; more belongs in a toolbar. */
23
31
  readonly actions?: (id: WindowId) => ReactNode
24
32
  /** Shown when no window is open. */
@@ -76,7 +84,7 @@ const halfFrame = (stage: HTMLElement, side: 'start' | 'end'): Frame => {
76
84
  return { x: side === 'start' ? area.x : area.x + width + area.gap, y: area.y, width, height: area.height }
77
85
  }
78
86
 
79
- export function Desktop({ renderWindow, title, note, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
87
+ export function Desktop({ renderWindow, title, note, info, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
80
88
  const desk = useDesk()
81
89
  const state = useDeskState()
82
90
  const stage = useRef<HTMLDivElement>(null)
@@ -87,6 +95,51 @@ export function Desktop({ renderWindow, title, note, actions, empty, loading, fa
87
95
  desk.setStage(() => ({ width: stage.current?.clientWidth ?? 1024, height: stage.current?.clientHeight ?? 768 }))
88
96
  }, [desk])
89
97
 
98
+ /*
99
+ * The desk changing size is not a layout decision, but it can undo one: unplug an external
100
+ * display and the windows you spread across it are past the edge of the laptop screen, where
101
+ * nothing can drag them back. They are brought in; plug the display back in and they go out
102
+ * again exactly as they were.
103
+ *
104
+ * Filled windows need none of this — they are whatever size the desk is — so a desk of only
105
+ * filled windows never commits anything here.
106
+ */
107
+ useEffect(() => {
108
+ const element = stage.current
109
+ if (!element || mode !== 'desktop') return undefined
110
+ let frame = 0
111
+ // Once per paint: dragging the browser window's edge fires this continuously.
112
+ const refit = () => {
113
+ cancelAnimationFrame(frame)
114
+ frame = requestAnimationFrame(() => {
115
+ const fit = desk.fitToStage()
116
+ /*
117
+ * One window that no longer fits is brought in and made smaller, and that is enough. Two or
118
+ * more means the layout itself is gone: each was clamped on its own, so windows that sat
119
+ * side by side now sit on top of each other, and the desk is left looking like one window.
120
+ * At that point it is laid out the way Arrange would — which is a layout for THIS screen,
121
+ * while the frames from the big one are remembered and come back with it.
122
+ */
123
+ if (fit.squeezed.length > 1) arrangeNow(desk, element)
124
+ })
125
+ }
126
+ // The stage can change size without the window doing so — a sidebar opening beside it — so the
127
+ // element is watched where that can be watched, and the window is the fallback where it cannot.
128
+ if (typeof ResizeObserver === 'undefined') {
129
+ addEventListener('resize', refit)
130
+ return () => {
131
+ cancelAnimationFrame(frame)
132
+ removeEventListener('resize', refit)
133
+ }
134
+ }
135
+ const observer = new ResizeObserver(refit)
136
+ observer.observe(element)
137
+ return () => {
138
+ cancelAnimationFrame(frame)
139
+ observer.disconnect()
140
+ }
141
+ }, [desk, mode])
142
+
90
143
  useEffect(() => (stage.current ? addDeskCommands(stage.current, desk) : undefined), [desk])
91
144
 
92
145
  // Arrange lays every window out once, as free windows that can be moved again straight away.
@@ -96,26 +149,7 @@ export function Desktop({ renderWindow, title, note, actions, empty, loading, fa
96
149
  return addCommandHandler(
97
150
  element,
98
151
  DeskCommands.arrange,
99
- () => {
100
- const { windows, stack } = desk.getState()
101
- const [focused, previous] = [stack.at(-1), stack.at(-2)]
102
- if (windows.length === 1) {
103
- if (focused) desk.fill(focused)
104
- return
105
- }
106
- const frames = arrangement(
107
- windows.map(w => w.id),
108
- focused ?? null,
109
- previous ?? null,
110
- arrangementArea(element),
111
- { minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT },
112
- )
113
- desk.placeAll(frames)
114
- // Windows that cascade come in front of the half they cascade over; the focused window stays in front of all.
115
- if (previous) desk.focus(previous)
116
- windows.forEach(w => w.id !== focused && w.id !== previous && desk.focus(w.id))
117
- if (focused) desk.focus(focused)
118
- },
152
+ () => arrangeNow(desk, element),
119
153
  {
120
154
  enabled: () => {
121
155
  const { windows } = desk.getState()
@@ -157,6 +191,7 @@ export function Desktop({ renderWindow, title, note, actions, empty, loading, fa
157
191
  hidden={mode === 'fullscreen' && window.id !== focused}
158
192
  title={title(window.id)}
159
193
  note={note?.(window.id)}
194
+ info={info?.(window.id)}
160
195
  actions={actions?.(window.id)}
161
196
  >
162
197
  {/* Memoised on the id and the render function: moving or focusing a window
@@ -192,11 +227,34 @@ interface WindowViewProps {
192
227
  readonly focused: boolean
193
228
  readonly title: ReactNode
194
229
  readonly note?: ReactNode
230
+ readonly info?: ReactNode
195
231
  readonly actions?: ReactNode
196
232
  readonly children: ReactNode
197
233
  }
198
234
 
199
235
  /** Moving by the title bar, or resizing from the corner or from the left, right or bottom edge. */
236
+ /** Lays every open window out at once: what Window → Arrange does, and what a shrunken desk needs. */
237
+ function arrangeNow(desk: Desk, element: HTMLElement) {
238
+ const { windows, stack } = desk.getState()
239
+ const [focused, previous] = [stack.at(-1), stack.at(-2)]
240
+ if (windows.length === 1) {
241
+ if (focused) desk.fill(focused)
242
+ return
243
+ }
244
+ const frames = arrangement(
245
+ windows.map(w => w.id),
246
+ focused ?? null,
247
+ previous ?? null,
248
+ arrangementArea(element),
249
+ { minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT },
250
+ )
251
+ desk.placeAll(frames)
252
+ // Windows that cascade come in front of the half they cascade over; the focused window stays in front of all.
253
+ if (previous) desk.focus(previous)
254
+ windows.forEach(w => w.id !== focused && w.id !== previous && desk.focus(w.id))
255
+ if (focused) desk.focus(focused)
256
+ }
257
+
200
258
  type Gesture = 'move' | 'resize' | 'left' | 'right' | 'bottom'
201
259
 
202
260
  /** The frame a gesture makes of `origin` after the pointer has travelled dx, dy. */
@@ -218,7 +276,7 @@ function reshape(gesture: Gesture, origin: Frame, dx: number, dy: number): Frame
218
276
  }
219
277
  }
220
278
 
221
- function WindowView({ window, layout, hidden, depth, focused, title, note, actions, children }: WindowViewProps) {
279
+ function WindowView({ window, layout, hidden, depth, focused, title, note, info, actions, children }: WindowViewProps) {
222
280
  const desk = useDesk()
223
281
  // While dragging, the frame lives here and commits once on release, so a drag
224
282
  // re-renders one window rather than notifying every subscriber per pixel.
@@ -340,6 +398,15 @@ function WindowView({ window, layout, hidden, depth, focused, title, note, actio
340
398
  {title}
341
399
  </h2>
342
400
  {note != null && note !== false && <p className="desk-window-note">{note}</p>}
401
+ {info != null && info !== false && (
402
+ <InfoTip
403
+ className="desk-window-info"
404
+ label={typeof title === 'string' ? `About ${title}` : 'About this window'}
405
+ align="end"
406
+ >
407
+ {info}
408
+ </InfoTip>
409
+ )}
343
410
  {actions && <div className="desk-window-actions">{actions}</div>}
344
411
  </header>
345
412
  <div className="desk-body">{children}</div>