@liberation-data/desk 0.4.0 → 0.7.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.
Files changed (46) hide show
  1. package/README.md +63 -2
  2. package/dist/core/desk.d.ts +13 -0
  3. package/dist/core/desk.d.ts.map +1 -1
  4. package/dist/core/desk.js +55 -1
  5. package/dist/core/desk.js.map +1 -1
  6. package/dist/core/index.d.ts +2 -2
  7. package/dist/core/index.d.ts.map +1 -1
  8. package/dist/core/types.d.ts +7 -0
  9. package/dist/core/types.d.ts.map +1 -1
  10. package/dist/css/apps.css +57 -0
  11. package/dist/css/controls.css +975 -0
  12. package/dist/css/conversation.css +232 -0
  13. package/dist/css/dock.css +272 -0
  14. package/dist/css/menus.css +204 -0
  15. package/dist/css/overlays.css +237 -0
  16. package/dist/css/search.css +149 -0
  17. package/dist/css/setup.css +284 -0
  18. package/dist/css/tokens.css +90 -0
  19. package/dist/css/windows.css +321 -0
  20. package/dist/desk.css +1080 -1070
  21. package/dist/react/Desktop.d.ts +9 -2
  22. package/dist/react/Desktop.d.ts.map +1 -1
  23. package/dist/react/Desktop.js +85 -23
  24. package/dist/react/Desktop.js.map +1 -1
  25. package/dist/react/appFrame.d.ts +9 -2
  26. package/dist/react/appFrame.d.ts.map +1 -1
  27. package/dist/react/appFrame.js +26 -5
  28. package/dist/react/appFrame.js.map +1 -1
  29. package/llms.txt +12 -1
  30. package/package.json +4 -3
  31. package/src/core/desk.ts +67 -1
  32. package/src/core/index.ts +2 -1
  33. package/src/core/types.ts +8 -0
  34. package/src/css/apps.css +56 -0
  35. package/src/css/controls.css +974 -0
  36. package/src/css/conversation.css +231 -0
  37. package/src/css/dock.css +271 -0
  38. package/src/css/menus.css +203 -0
  39. package/src/css/overlays.css +236 -0
  40. package/src/css/search.css +148 -0
  41. package/src/css/setup.css +283 -0
  42. package/src/css/tokens.css +89 -0
  43. package/src/css/windows.css +320 -0
  44. package/src/react/Desktop.tsx +97 -26
  45. package/src/react/appFrame.tsx +48 -5
  46. package/src/desk.css +0 -2820
@@ -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,7 +2,8 @@ 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 { DeskWindow, Frame, WindowId } from '../core/types.js'
5
+ import type { Desk } from '../core/desk.js'
6
+ import type { DeskWindow, Frame, WindowId, WindowLimits } from '../core/types.js'
6
7
  import { arrangement } from './arrange.js'
7
8
  import { InfoTip } from './infoTip.js'
8
9
  import { useDesk, useDeskState, WindowContext } from './context.js'
@@ -20,6 +21,13 @@ export interface DesktopProps {
20
21
  * gives way to the actions and disappears before the title does when the window is narrow.
21
22
  */
22
23
  readonly note?: (id: WindowId) => ReactNode
24
+ /**
25
+ * How big this window may be. Most windows want the whole desk when filled and whatever they are
26
+ * dragged to when free, which is what `null` says. A window whose content has a natural size —
27
+ * an About box, a small form — says so, and is never stretched past it, however it is filled,
28
+ * arranged or dragged.
29
+ */
30
+ readonly limits?: (id: WindowId) => WindowLimits | null
23
31
  /**
24
32
  * What this window is, behind an (i) at the end of its title bar — the same place in every window,
25
33
  * so a person who wonders what they are looking at always knows where to ask. Explanation only: a
@@ -83,7 +91,7 @@ const halfFrame = (stage: HTMLElement, side: 'start' | 'end'): Frame => {
83
91
  return { x: side === 'start' ? area.x : area.x + width + area.gap, y: area.y, width, height: area.height }
84
92
  }
85
93
 
86
- export function Desktop({ renderWindow, title, note, info, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
94
+ export function Desktop({ renderWindow, title, note, info, actions, limits, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
87
95
  const desk = useDesk()
88
96
  const state = useDeskState()
89
97
  const stage = useRef<HTMLDivElement>(null)
@@ -94,6 +102,51 @@ export function Desktop({ renderWindow, title, note, info, actions, empty, loadi
94
102
  desk.setStage(() => ({ width: stage.current?.clientWidth ?? 1024, height: stage.current?.clientHeight ?? 768 }))
95
103
  }, [desk])
96
104
 
105
+ /*
106
+ * The desk changing size is not a layout decision, but it can undo one: unplug an external
107
+ * display and the windows you spread across it are past the edge of the laptop screen, where
108
+ * nothing can drag them back. They are brought in; plug the display back in and they go out
109
+ * again exactly as they were.
110
+ *
111
+ * Filled windows need none of this — they are whatever size the desk is — so a desk of only
112
+ * filled windows never commits anything here.
113
+ */
114
+ useEffect(() => {
115
+ const element = stage.current
116
+ if (!element || mode !== 'desktop') return undefined
117
+ let frame = 0
118
+ // Once per paint: dragging the browser window's edge fires this continuously.
119
+ const refit = () => {
120
+ cancelAnimationFrame(frame)
121
+ frame = requestAnimationFrame(() => {
122
+ const fit = desk.fitToStage()
123
+ /*
124
+ * One window that no longer fits is brought in and made smaller, and that is enough. Two or
125
+ * more means the layout itself is gone: each was clamped on its own, so windows that sat
126
+ * side by side now sit on top of each other, and the desk is left looking like one window.
127
+ * At that point it is laid out the way Arrange would — which is a layout for THIS screen,
128
+ * while the frames from the big one are remembered and come back with it.
129
+ */
130
+ if (fit.squeezed.length > 1) arrangeNow(desk, element)
131
+ })
132
+ }
133
+ // The stage can change size without the window doing so — a sidebar opening beside it — so the
134
+ // element is watched where that can be watched, and the window is the fallback where it cannot.
135
+ if (typeof ResizeObserver === 'undefined') {
136
+ addEventListener('resize', refit)
137
+ return () => {
138
+ cancelAnimationFrame(frame)
139
+ removeEventListener('resize', refit)
140
+ }
141
+ }
142
+ const observer = new ResizeObserver(refit)
143
+ observer.observe(element)
144
+ return () => {
145
+ cancelAnimationFrame(frame)
146
+ observer.disconnect()
147
+ }
148
+ }, [desk, mode])
149
+
97
150
  useEffect(() => (stage.current ? addDeskCommands(stage.current, desk) : undefined), [desk])
98
151
 
99
152
  // Arrange lays every window out once, as free windows that can be moved again straight away.
@@ -103,26 +156,7 @@ export function Desktop({ renderWindow, title, note, info, actions, empty, loadi
103
156
  return addCommandHandler(
104
157
  element,
105
158
  DeskCommands.arrange,
106
- () => {
107
- const { windows, stack } = desk.getState()
108
- const [focused, previous] = [stack.at(-1), stack.at(-2)]
109
- if (windows.length === 1) {
110
- if (focused) desk.fill(focused)
111
- return
112
- }
113
- const frames = arrangement(
114
- windows.map(w => w.id),
115
- focused ?? null,
116
- previous ?? null,
117
- arrangementArea(element),
118
- { minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT },
119
- )
120
- desk.placeAll(frames)
121
- // Windows that cascade come in front of the half they cascade over; the focused window stays in front of all.
122
- if (previous) desk.focus(previous)
123
- windows.forEach(w => w.id !== focused && w.id !== previous && desk.focus(w.id))
124
- if (focused) desk.focus(focused)
125
- },
159
+ () => arrangeNow(desk, element),
126
160
  {
127
161
  enabled: () => {
128
162
  const { windows } = desk.getState()
@@ -165,6 +199,7 @@ export function Desktop({ renderWindow, title, note, info, actions, empty, loadi
165
199
  title={title(window.id)}
166
200
  note={note?.(window.id)}
167
201
  info={info?.(window.id)}
202
+ limits={limits?.(window.id) ?? null}
168
203
  actions={actions?.(window.id)}
169
204
  >
170
205
  {/* Memoised on the id and the render function: moving or focusing a window
@@ -201,11 +236,34 @@ interface WindowViewProps {
201
236
  readonly title: ReactNode
202
237
  readonly note?: ReactNode
203
238
  readonly info?: ReactNode
239
+ readonly limits?: WindowLimits | null
204
240
  readonly actions?: ReactNode
205
241
  readonly children: ReactNode
206
242
  }
207
243
 
208
244
  /** Moving by the title bar, or resizing from the corner or from the left, right or bottom edge. */
245
+ /** Lays every open window out at once: what Window → Arrange does, and what a shrunken desk needs. */
246
+ function arrangeNow(desk: Desk, element: HTMLElement) {
247
+ const { windows, stack } = desk.getState()
248
+ const [focused, previous] = [stack.at(-1), stack.at(-2)]
249
+ if (windows.length === 1) {
250
+ if (focused) desk.fill(focused)
251
+ return
252
+ }
253
+ const frames = arrangement(
254
+ windows.map(w => w.id),
255
+ focused ?? null,
256
+ previous ?? null,
257
+ arrangementArea(element),
258
+ { minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT },
259
+ )
260
+ desk.placeAll(frames)
261
+ // Windows that cascade come in front of the half they cascade over; the focused window stays in front of all.
262
+ if (previous) desk.focus(previous)
263
+ windows.forEach(w => w.id !== focused && w.id !== previous && desk.focus(w.id))
264
+ if (focused) desk.focus(focused)
265
+ }
266
+
209
267
  type Gesture = 'move' | 'resize' | 'left' | 'right' | 'bottom'
210
268
 
211
269
  /** The frame a gesture makes of `origin` after the pointer has travelled dx, dy. */
@@ -227,7 +285,7 @@ function reshape(gesture: Gesture, origin: Frame, dx: number, dy: number): Frame
227
285
  }
228
286
  }
229
287
 
230
- function WindowView({ window, layout, hidden, depth, focused, title, note, info, actions, children }: WindowViewProps) {
288
+ function WindowView({ window, layout, hidden, depth, focused, title, note, info, actions, limits, children }: WindowViewProps) {
231
289
  const desk = useDesk()
232
290
  // While dragging, the frame lives here and commits once on release, so a drag
233
291
  // re-renders one window rather than notifying every subscriber per pixel.
@@ -306,12 +364,25 @@ function WindowView({ window, layout, hidden, depth, focused, title, note, info,
306
364
  }
307
365
 
308
366
  const frame = layout === 'desktop' ? (live ?? (window.mode === 'floating' ? window.frame : null)) : null
367
+ /*
368
+ * A window that says how big it may be is capped in the browser rather than in the state: filling
369
+ * the desk, being arranged into a cell and being dragged wider all end at the same place, and the
370
+ * window sits in the middle of whatever room it was given rather than pinned to a corner of it.
371
+ */
372
+ const capped: CSSProperties = {
373
+ ...(limits?.maxWidth ? { maxWidth: limits.maxWidth } : {}),
374
+ ...(limits?.maxHeight ? { maxHeight: limits.maxHeight } : {}),
375
+ ...(limits?.minWidth ? { minWidth: limits.minWidth } : {}),
376
+ ...(limits?.minHeight ? { minHeight: limits.minHeight } : {}),
377
+ // Filled and capped: it sits in the middle of the room, not in the corner of it.
378
+ ...(limits && (limits.maxWidth || limits.maxHeight) && !frame ? { margin: 'auto' } : {}),
379
+ }
309
380
  const style: CSSProperties | undefined =
310
381
  layout !== 'desktop'
311
- ? undefined
382
+ ? capped
312
383
  : frame
313
- ? { left: frame.x, top: frame.y, width: frame.width, height: frame.height, zIndex: 10 + depth }
314
- : { zIndex: 10 + depth }
384
+ ? { left: frame.x, top: frame.y, width: frame.width, height: frame.height, zIndex: 10 + depth, ...capped }
385
+ : { zIndex: 10 + depth, ...capped }
315
386
 
316
387
  return (
317
388
  <WindowContext.Provider value={context}>