@liberation-data/desk 0.1.0 → 0.3.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 (67) hide show
  1. package/README.md +126 -1
  2. package/dist/core/desk.d.ts.map +1 -1
  3. package/dist/core/desk.js +19 -2
  4. package/dist/core/desk.js.map +1 -1
  5. package/dist/core/index.d.ts +2 -0
  6. package/dist/core/index.d.ts.map +1 -1
  7. package/dist/core/index.js +1 -0
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/layouts.d.ts +26 -0
  10. package/dist/core/layouts.d.ts.map +1 -0
  11. package/dist/core/layouts.js +70 -0
  12. package/dist/core/layouts.js.map +1 -0
  13. package/dist/core/types.d.ts +3 -0
  14. package/dist/core/types.d.ts.map +1 -1
  15. package/dist/desk.css +252 -1
  16. package/dist/react/Desktop.d.ts +7 -1
  17. package/dist/react/Desktop.d.ts.map +1 -1
  18. package/dist/react/Desktop.js +4 -4
  19. package/dist/react/Desktop.js.map +1 -1
  20. package/dist/react/Dock.d.ts +16 -1
  21. package/dist/react/Dock.d.ts.map +1 -1
  22. package/dist/react/Dock.js +44 -6
  23. package/dist/react/Dock.js.map +1 -1
  24. package/dist/react/MenuBar.d.ts +16 -0
  25. package/dist/react/MenuBar.d.ts.map +1 -1
  26. package/dist/react/MenuBar.js +36 -27
  27. package/dist/react/MenuBar.js.map +1 -1
  28. package/dist/react/context.d.ts +2 -0
  29. package/dist/react/context.d.ts.map +1 -1
  30. package/dist/react/context.js +2 -0
  31. package/dist/react/context.js.map +1 -1
  32. package/dist/react/contextMenu.d.ts +22 -0
  33. package/dist/react/contextMenu.d.ts.map +1 -0
  34. package/dist/react/contextMenu.js +146 -0
  35. package/dist/react/contextMenu.js.map +1 -0
  36. package/dist/react/iconView.d.ts +34 -0
  37. package/dist/react/iconView.d.ts.map +1 -0
  38. package/dist/react/iconView.js +112 -0
  39. package/dist/react/iconView.js.map +1 -0
  40. package/dist/react/index.d.ts +10 -2
  41. package/dist/react/index.d.ts.map +1 -1
  42. package/dist/react/index.js +5 -1
  43. package/dist/react/index.js.map +1 -1
  44. package/dist/react/infoTip.d.ts +11 -0
  45. package/dist/react/infoTip.d.ts.map +1 -0
  46. package/dist/react/infoTip.js +8 -0
  47. package/dist/react/infoTip.js.map +1 -0
  48. package/dist/react/pane.d.ts +28 -0
  49. package/dist/react/pane.d.ts.map +1 -0
  50. package/dist/react/pane.js +9 -0
  51. package/dist/react/pane.js.map +1 -0
  52. package/llms.txt +34 -4
  53. package/package.json +1 -1
  54. package/src/core/desk.ts +17 -2
  55. package/src/core/index.ts +2 -0
  56. package/src/core/layouts.ts +88 -0
  57. package/src/core/types.ts +4 -0
  58. package/src/desk.css +252 -1
  59. package/src/react/Desktop.tsx +11 -2
  60. package/src/react/Dock.tsx +74 -5
  61. package/src/react/MenuBar.tsx +43 -30
  62. package/src/react/context.tsx +3 -0
  63. package/src/react/contextMenu.tsx +198 -0
  64. package/src/react/iconView.tsx +206 -0
  65. package/src/react/index.ts +10 -2
  66. package/src/react/infoTip.tsx +48 -0
  67. package/src/react/pane.tsx +65 -0
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface InfoTipProps {
3
+ /** What it explains, e.g. "About these logs" — the button's name and the popover's. */
4
+ readonly label: string;
5
+ readonly children: ReactNode;
6
+ readonly placement?: 'below' | 'above';
7
+ readonly align?: 'start' | 'end';
8
+ readonly className?: string;
9
+ }
10
+ export declare function InfoTip({ label, children, placement, align, className }: InfoTipProps): import("react").JSX.Element;
11
+ //# sourceMappingURL=infoTip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"infoTip.d.ts","sourceRoot":"","sources":["../../src/react/infoTip.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAYtC,MAAM,WAAW,YAAY;IAC3B,uFAAuF;IACvF,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,GAAG,OAAO,CAAA;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,CAAA;IAChC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,wBAAgB,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAmB,EAAE,KAAe,EAAE,SAAS,EAAE,EAAE,YAAY,+BAyBzG"}
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Popover } from './overlays.js';
4
+ export function InfoTip({ label, children, placement = 'below', align = 'start', className }) {
5
+ const [open, setOpen] = useState(false);
6
+ return (_jsx(Popover, { open: open, onOpenChange: setOpen, label: label, placement: placement, align: align, trigger: props => (_jsx("button", { ...props, ref: props.ref, type: "button", className: ['desk-infotip', className].filter(Boolean).join(' '), "aria-label": label, "data-open": open || undefined, children: _jsx("span", { "aria-hidden": "true", children: "i" }) })), children: _jsx("div", { className: "desk-infotip-body", children: children }) }));
7
+ }
8
+ //# sourceMappingURL=infoTip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"infoTip.js","sourceRoot":"","sources":["../../src/react/infoTip.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEhC,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AAoBvC,MAAM,UAAU,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,GAAG,OAAO,EAAE,KAAK,GAAG,OAAO,EAAE,SAAS,EAAgB;IACxG,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACvC,OAAO,CACL,KAAC,OAAO,IACN,IAAI,EAAE,IAAI,EACV,YAAY,EAAE,OAAO,EACrB,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC,CAChB,oBACM,KAAK,EACT,GAAG,EAAE,KAAK,CAAC,GAAG,EACd,IAAI,EAAC,QAAQ,EACb,SAAS,EAAE,CAAC,cAAc,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,gBACpD,KAAK,eACN,IAAI,IAAI,SAAS,YAE5B,8BAAkB,MAAM,kBAAS,GAC1B,CACV,YAED,cAAK,SAAS,EAAC,mBAAmB,YAAE,QAAQ,GAAO,GAC3C,CACX,CAAA;AACH,CAAC"}
@@ -0,0 +1,28 @@
1
+ import type { Ref, UIEvent, ReactNode } from 'react';
2
+ export interface ToolbarProps {
3
+ /** Controls, in reading order. Anything given `className="desk-grow"` takes the spare room. */
4
+ readonly children: ReactNode;
5
+ /** Pushed to the trailing end: what acts on everything here, or the state of it. */
6
+ readonly trailing?: ReactNode;
7
+ /** Names the row for assistive technology when it holds more than one thing: "Log controls". */
8
+ readonly label?: string;
9
+ readonly className?: string;
10
+ }
11
+ /** One aligned row of controls. Every item sits on the same centre line, whatever its height. */
12
+ export declare function Toolbar({ children, trailing, label, className }: ToolbarProps): import("react").JSX.Element;
13
+ export interface PaneProps {
14
+ /** Stays put at the top: a Toolbar, usually. */
15
+ readonly header?: ReactNode;
16
+ /** Stays put at the bottom: a count, a status line, the buttons of a form. */
17
+ readonly footer?: ReactNode;
18
+ /** Fills what is left and scrolls, on its own. */
19
+ readonly children: ReactNode;
20
+ /** The scrolling part is the thing being read, so it can be named and reached by the keyboard. */
21
+ readonly label?: string;
22
+ /** The scrolling element itself: for reading where it sits, or sending it to the bottom. */
23
+ readonly bodyRef?: Ref<HTMLDivElement>;
24
+ readonly onScroll?: (event: UIEvent<HTMLDivElement>) => void;
25
+ readonly className?: string;
26
+ }
27
+ export declare function Pane({ header, footer, children, label, bodyRef, onScroll, className }: PaneProps): import("react").JSX.Element;
28
+ //# sourceMappingURL=pane.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pane.d.ts","sourceRoot":"","sources":["../../src/react/pane.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAWpD,MAAM,WAAW,YAAY;IAC3B,+FAA+F;IAC/F,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,oFAAoF;IACpF,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;IAC7B,gGAAgG;IAChG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,iGAAiG;AACjG,wBAAgB,OAAO,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,YAAY,+BAU7E;AAED,MAAM,WAAW,SAAS;IACxB,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;IAC3B,kDAAkD;IAClD,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,kGAAkG;IAClG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAA;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,CAAC,KAAK,IAAI,CAAA;IAC5D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,wBAAgB,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,SAAS,+BAehG"}
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** One aligned row of controls. Every item sits on the same centre line, whatever its height. */
3
+ export function Toolbar({ children, trailing, label, className }) {
4
+ return (_jsxs("div", { ...(label ? { role: 'group', 'aria-label': label } : {}), className: ['desk-toolbar', className].filter(Boolean).join(' '), children: [children, trailing != null && _jsx("div", { className: "desk-toolbar-trailing", children: trailing })] }));
5
+ }
6
+ export function Pane({ header, footer, children, label, bodyRef, onScroll, className }) {
7
+ return (_jsxs("div", { className: ['desk-pane', className].filter(Boolean).join(' '), children: [header != null && _jsx("div", { className: "desk-pane-header", children: header }), _jsx("div", { ref: bodyRef, className: "desk-pane-body", onScroll: onScroll, ...(label ? { role: 'region', 'aria-label': label, tabIndex: 0 } : {}), children: children }), footer != null && _jsx("div", { className: "desk-pane-footer", children: footer })] }));
8
+ }
9
+ //# sourceMappingURL=pane.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pane.js","sourceRoot":"","sources":["../../src/react/pane.tsx"],"names":[],"mappings":";AAqBA,iGAAiG;AACjG,MAAM,UAAU,OAAO,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAgB;IAC5E,OAAO,CACL,kBACM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EACzD,SAAS,EAAE,CAAC,cAAc,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,aAE/D,QAAQ,EACR,QAAQ,IAAI,IAAI,IAAI,cAAK,SAAS,EAAC,uBAAuB,YAAE,QAAQ,GAAO,IACxE,CACP,CAAA;AACH,CAAC;AAiBD,MAAM,UAAU,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAa;IAC/F,OAAO,CACL,eAAK,SAAS,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,aAC/D,MAAM,IAAI,IAAI,IAAI,cAAK,SAAS,EAAC,kBAAkB,YAAE,MAAM,GAAO,EACnE,cACE,GAAG,EAAE,OAAO,EACZ,SAAS,EAAC,gBAAgB,EAC1B,QAAQ,EAAE,QAAQ,KACd,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,YAEtE,QAAQ,GACL,EACL,MAAM,IAAI,IAAI,IAAI,cAAK,SAAS,EAAC,kBAAkB,YAAE,MAAM,GAAO,IAC/D,CACP,CAAA;AACH,CAAC"}
package/llms.txt CHANGED
@@ -46,7 +46,12 @@ Rules that are easy to get wrong:
46
46
 
47
47
  ```ts
48
48
  createDesk(options?: DeskOptions): Desk
49
- interface DeskOptions { cascade?: Partial<CascadeOptions>; stage?: () => Size; initial?: DeskState }
49
+ interface DeskOptions { cascade?: Partial<CascadeOptions>; stage?: () => Size; initial?: DeskState; layouts?: LayoutStore }
50
+ // layouts: remembers each window type's last mode/frame (saved by float/fill/zoom, not placeAll or open frames);
51
+ // open() without mode/frame restores it, fitted to the stage (fills if too small); more instances step +x,+y.
52
+ localLayoutStore(key): LayoutStore // localStorage 'desk.layouts.<key>'
53
+ interface LayoutStore { load(type): SavedLayout | undefined; save(type, layout: SavedLayout): void }
54
+ fitFrame(frame, stage, min?): Frame | null; stepFrom(frame, open: Frame[], stage, step, margin): Frame
50
55
 
51
56
  interface Desk {
52
57
  getState(): DeskState
@@ -177,6 +182,7 @@ useWindowId(): string | null // inside a window's content
177
182
  <Desktop
178
183
  renderWindow={(id) => ReactNode} // memoised: chrome re-renders, content does not
179
184
  title={(id) => ReactNode}
185
+ note?={(id) => ReactNode} // quiet text in the title bar: what this window is looking at
180
186
  actions?={(id) => ReactNode} // window-wide controls in the title bar
181
187
  loading?={(id) => ReactNode} // inside a window while its code loads
182
188
  failed?={(id, error, reload) => ReactNode} // inside a window that could not open
@@ -196,17 +202,32 @@ Every window has its own Suspense and error boundary: one window loading or thro
196
202
  the menu bar or the dock. A failure is logged with `console.error` and shows Reload, which remounts the
197
203
  window; `lazyWindow` retries its import on reload, unlike `React.lazy`, which caches a failed import.
198
204
 
205
+ ### Inside a window
206
+
207
+ ```tsx
208
+ <Pane label?={string} header?={ReactNode} footer?={ReactNode} bodyRef?={Ref} onScroll?={fn}>{scrolling content}</Pane>
209
+ <Toolbar label?={string} trailing?={ReactNode}>{controls}</Toolbar> // className="desk-grow" takes spare room
210
+ <InfoTip label={string} placement?={'below'|'above'} align?={'start'|'end'}>{explanation}</InfoTip>
211
+ ```
212
+
213
+ A pane fills its window and scrolls only its middle, so the window never scrolls and the toolbar stays put.
214
+ A named pane is a focusable region, so it scrolls from the keyboard. InfoTip is an (i) popover for
215
+ explanation only — never a warning, which belongs in front of people.
216
+
199
217
  ### Dock
200
218
 
201
219
  ```tsx
202
- <Dock entries={DockEntry[]} label?="Dock" placement?={'overlay' | 'inline'} />
203
- dockItem({ id, label, icon, window?, description?, badge?, onSelect?, disabled? })
220
+ <Dock entries={DockEntry[]} label?="Dock" placement?={'overlay' | 'inline'}
221
+ pins?={{ accepts, onPin: (drag, before: id | null) => void, onMove?: (id, before: id | null) => void }} />
222
+ dockItem({ id, label, icon, window?, description?, badge?, onSelect?, disabled?, contextMenu?: () => MenuItem[], movable? })
204
223
  dockStack({ id, label, items: DockItem[], icon? })
205
224
  dockSeparator(id)
206
225
  ```
207
226
 
208
227
  An item opens `window ?? id` and shows a dot while that window is open, accent when it is key. A stack fans
209
228
  out above the dock. One tab stop; arrow keys along it; Escape folds a stack away.
229
+ Pins: the app stores kept items and passes them back as entries. A drop on the dock keeps at the end; on a
230
+ `movable` item, in front of it. Movable items drag along the dock (type DOCK_ITEM, payload id).
210
231
 
211
232
  ### Menu bar
212
233
 
@@ -218,6 +239,15 @@ menuCommand(label, command, { shortcut?, checked?, detail?, args? })
218
239
  menuAction(label, onSelect, { disabled?, shortcut?, checked?, detail? })
219
240
  menuSeparator(); menuHeader(label)
220
241
  windowMenuItems(state, focus, title): MenuItem[]
242
+
243
+ const { target, menu, open } = useContextMenu({ label, items: () => MenuItem[], disabled? })
244
+ // spread `target` (onContextMenu, onKeyDown) on the element; render `menu` anywhere (portal).
245
+ // Right-click opens at the pointer; Menu key / Shift+F10 at the element. Commands run from that element.
246
+
247
+ <IconView label items={{id, label, icon, subtitle?, badge?, disabled?}[]} onOpen={item => …}
248
+ openOn?="double"|"single" selected? onSelectionChange? contextMenu?={item => MenuItem[]}
249
+ drag?={{ type, payload: item => unknown }} empty? className? />
250
+ // click selects, double-click / Return opens (single: click opens); arrows, Home/End, type-ahead. Needs a DeskProvider.
221
251
  ```
222
252
 
223
253
  Command items are enabled via `canPerform` as the menu opens, and menus bind their own shortcuts. Opening a
@@ -406,5 +436,5 @@ in the accessibility tree.
406
436
  - A `DeskProvider` already carries an event bus; `BusProvider` is only for supplying or sharing one.
407
437
  - `renderWindow` must be stable (module scope or `useCallback`) for the memoisation to hold.
408
438
  - `parse`/`syncWithLocation` warn once when no `isKnown` is given: unknown ids from a URL are then trusted.
409
- - `npm run size` enforces a gzipped budget: core 10 KiB, react 46 KiB, css 10 KiB.
439
+ - `npm run size` enforces a gzipped budget: core 11 KiB, react 46 KiB, css 10 KiB.
410
440
  - The package is ESM only, with `"sideEffects": ["**/*.css"]`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liberation-data/desk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A desktop for the web, for React: windows, a dock, menus and commands, controls, tours and setup assistants.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jasper Blues",
package/src/core/desk.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  Size,
9
9
  WindowId,
10
10
  } from './types.js'
11
+ import { fitFrame, stepFrom } from './layouts.js'
11
12
 
12
13
  export interface Desk {
13
14
  getState(): DeskState
@@ -106,6 +107,7 @@ export function createDesk(options: DeskOptions = {}): Desk {
106
107
  let stage = options.stage ?? (() => DEFAULT_STAGE)
107
108
  let state = normalise(options.initial ?? EMPTY)
108
109
  const listeners = new Set<(state: DeskState) => void>()
110
+ const layouts = options.layouts
109
111
 
110
112
  const commit = (next: DeskState) => {
111
113
  if (next === state) return
@@ -142,6 +144,7 @@ export function createDesk(options: DeskOptions = {}): Desk {
142
144
  if (window.mode === 'floating' && !frame) return
143
145
  const next = frame ?? fits(remembered.get(id)) ?? nextFrame(state)
144
146
  if (frame) remembered.set(id, frame)
147
+ layouts?.save(windowType(id), { mode: 'floating', frame: next })
145
148
  commit(toFront(replace(state, { id, mode: 'floating', frame: next }), id))
146
149
  }
147
150
 
@@ -149,6 +152,7 @@ export function createDesk(options: DeskOptions = {}): Desk {
149
152
  const window = find(id)
150
153
  if (!window || window.mode === 'filled') return
151
154
  remembered.set(id, window.frame)
155
+ layouts?.save(windowType(id), { mode: 'filled' })
152
156
  commit(toFront(replace(state, { id, mode: 'filled' }), id))
153
157
  }
154
158
 
@@ -167,8 +171,19 @@ export function createDesk(options: DeskOptions = {}): Desk {
167
171
  commit(toFront(state, id))
168
172
  return
169
173
  }
170
- const mode = opts.mode ?? (opts.frame ? 'floating' : 'filled')
171
- const window: DeskWindow = mode === 'filled' ? { id, mode } : { id, mode, frame: opts.frame ?? nextFrame(state) }
174
+ const saved = opts.mode || opts.frame ? undefined : layouts?.load(windowType(id))
175
+ const kept = saved?.mode === 'floating' ? fitFrame(saved.frame, stage()) : null
176
+ const mode = opts.mode ?? (opts.frame || kept ? 'floating' : 'filled')
177
+ const frame = () => {
178
+ if (opts.frame) return opts.frame
179
+ if (!kept) return nextFrame(state)
180
+ const siblings = state.stack.flatMap(other => {
181
+ const w = find(other)
182
+ return w?.mode === 'floating' && windowType(w.id) === windowType(id) ? [w.frame] : []
183
+ })
184
+ return stepFrom(kept, siblings, stage(), cascade.step, cascade.margin)
185
+ }
186
+ const window: DeskWindow = mode === 'filled' ? { id, mode } : { id, mode, frame: frame() }
172
187
  commit({ windows: [...state.windows, window], stack: [...state.stack, id] })
173
188
  },
174
189
 
package/src/core/index.ts CHANGED
@@ -29,6 +29,8 @@ export { bindShortcuts, formatShortcut, isApplePlatform, isInstalledApp, matches
29
29
  export type { Keymap, Shortcut, ShortcutOptions } from './shortcuts.js'
30
30
  export { parse, serialize, syncWithLocation } from './location.js'
31
31
  export { isTitleCase, titleCase } from './titles.js'
32
+ export { fitFrame, localLayoutStore, stepFrom } from './layouts.js'
33
+ export type { LayoutStore, SavedLayout } from './layouts.js'
32
34
  export type { LocationEnv, LocationOptions } from './location.js'
33
35
  export type {
34
36
  CascadeOptions,
@@ -0,0 +1,88 @@
1
+ import type { Frame, Size } from './types.js'
2
+
3
+ /*
4
+ * Windows that come back the way they were left. Close a window you had made small and put in a
5
+ * corner, open it tomorrow, and it is small and in the corner again; one you zoomed to fill the desk
6
+ * fills it again.
7
+ *
8
+ * What is remembered is per kind of window, not per id: `query#2` opens where `query` was last left.
9
+ * A remembered frame is a promise about a screen that may have changed, so it is fitted to the
10
+ * current one: moved in until it is on the desk, made smaller if the desk is now smaller, and given
11
+ * up for filling if it would be too small to use. A second window of a kind already open steps down
12
+ * and to the right of it, as a cascade does, rather than opening exactly on top and hiding it.
13
+ */
14
+
15
+ export type SavedLayout = { readonly mode: 'filled' } | { readonly mode: 'floating'; readonly frame: Frame }
16
+
17
+ /** Where layouts are kept. Synchronous: a window opens now, not after a round trip. */
18
+ export interface LayoutStore {
19
+ load(type: string): SavedLayout | undefined
20
+ save(type: string, layout: SavedLayout): void
21
+ }
22
+
23
+ const MIN: Size = { width: 240, height: 160 }
24
+
25
+ /** Keeps layouts in this browser, under `desk.layouts.<key>`. */
26
+ export function localLayoutStore(key: string): LayoutStore {
27
+ const name = `desk.layouts.${key}`
28
+ const read = (): Record<string, SavedLayout> => {
29
+ try {
30
+ const raw = globalThis.localStorage?.getItem(name)
31
+ const parsed: unknown = raw ? JSON.parse(raw) : {}
32
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, SavedLayout>) : {}
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+ return {
38
+ load: type => valid(read()[type]),
39
+ save(type, layout) {
40
+ try {
41
+ globalThis.localStorage?.setItem(name, JSON.stringify({ ...read(), [type]: layout }))
42
+ } catch {
43
+ // Private windows and full storage: windows still open, just not where they were.
44
+ }
45
+ },
46
+ }
47
+ }
48
+
49
+ const finite = (...values: unknown[]) => values.every(v => typeof v === 'number' && Number.isFinite(v))
50
+
51
+ /** Storage is outside the program: anything that is not a layout is treated as none. */
52
+ function valid(layout: unknown): SavedLayout | undefined {
53
+ const l = layout as SavedLayout | undefined
54
+ if (l?.mode === 'filled') return l
55
+ if (l?.mode === 'floating' && l.frame && finite(l.frame.x, l.frame.y, l.frame.width, l.frame.height)) return l
56
+ return undefined
57
+ }
58
+
59
+ /** A remembered frame on today's desk: on screen, no bigger than the desk, or null if too small to use. */
60
+ export function fitFrame(frame: Frame, stage: Size, min: Size = MIN): Frame | null {
61
+ const width = Math.min(frame.width, stage.width)
62
+ const height = Math.min(frame.height, stage.height)
63
+ if (width < min.width || height < min.height) return null
64
+ return {
65
+ x: Math.max(0, Math.min(frame.x, stage.width - width)),
66
+ y: Math.max(0, Math.min(frame.y, stage.height - height)),
67
+ width,
68
+ height,
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Where another window of a kind goes when some are already free on the desk: a step down and right
74
+ * of the one in front, back to the top-left when that would run off the desk, and never exactly on
75
+ * top of one already there.
76
+ */
77
+ export function stepFrom(frame: Frame, open: readonly Frame[], stage: Size, step: { x: number; y: number }, margin: number): Frame {
78
+ const front = open.at(-1)
79
+ if (!front) return frame
80
+ const taken = (f: Frame) => open.some(o => o.x === f.x && o.y === f.y)
81
+ let next = { ...frame, x: front.x + step.x, y: front.y + step.y }
82
+ for (let tries = 0; tries <= open.length; tries++) {
83
+ if (next.x + next.width > stage.width || next.y + next.height > stage.height) next = { ...next, x: margin, y: margin }
84
+ if (!taken(next)) return next
85
+ next = { ...next, x: next.x + step.x, y: next.y + step.y }
86
+ }
87
+ return next
88
+ }
package/src/core/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { LayoutStore } from './layouts.js'
2
+
1
3
  export type WindowId = string
2
4
 
3
5
  export interface Frame {
@@ -40,6 +42,8 @@ export interface DeskOptions {
40
42
  /** Reports the stage's size, so floating windows open inside it. */
41
43
  readonly stage?: () => Size
42
44
  readonly initial?: DeskState
45
+ /** Remembers how each kind of window was left, and opens it that way again. */
46
+ readonly layouts?: LayoutStore
43
47
  }
44
48
 
45
49
  export interface OpenOptions {
package/src/desk.css CHANGED
@@ -216,6 +216,23 @@
216
216
  color: var(--desk-muted);
217
217
  }
218
218
 
219
+ .desk-window-note {
220
+ margin: 0 0 0 auto;
221
+ min-width: 0;
222
+ /* The title is what the window IS; the note is what it is looking at. In a narrow window the note
223
+ gives up its room first, and is cut before the title is. */
224
+ flex: 0 999 auto;
225
+ overflow: hidden;
226
+ text-overflow: ellipsis;
227
+ white-space: nowrap;
228
+ font-size: 11.5px;
229
+ color: var(--desk-muted);
230
+ }
231
+
232
+ .desk-window-note + .desk-window-actions {
233
+ margin-left: var(--desk-space-1);
234
+ }
235
+
219
236
  .desk-window-actions {
220
237
  margin-left: auto;
221
238
  display: flex;
@@ -372,7 +389,8 @@
372
389
  transition: transform .18s var(--desk-ease), margin .18s var(--desk-ease);
373
390
  }
374
391
 
375
- .desk-dock-item:disabled {
392
+ .desk-dock-item:disabled,
393
+ .desk-dock-item[aria-disabled="true"] {
376
394
  opacity: .45;
377
395
  cursor: not-allowed;
378
396
  }
@@ -658,6 +676,13 @@
658
676
  height: 15px;
659
677
  }
660
678
 
679
+ /* A context menu is the same menu, opened where the pointer is rather than under a title. */
680
+ .desk-context-menu {
681
+ position: fixed;
682
+ z-index: 1300;
683
+ outline: none;
684
+ }
685
+
661
686
  .desk-menubar-slot[data-align="end"] .desk-menu {
662
687
  left: auto;
663
688
  right: 0;
@@ -2548,3 +2573,229 @@
2548
2573
  border: 0;
2549
2574
  background: var(--desk-surface);
2550
2575
  }
2576
+
2577
+ /* Icon view: a Finder's icons, in rows that follow the window's width. */
2578
+ .desk-icon-view {
2579
+ list-style: none;
2580
+ margin: 0;
2581
+ padding: 12px;
2582
+ display: grid;
2583
+ grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
2584
+ align-content: start;
2585
+ gap: 8px;
2586
+ }
2587
+
2588
+ .desk-icon-cell {
2589
+ display: grid;
2590
+ justify-items: center;
2591
+ align-content: start;
2592
+ gap: 4px;
2593
+ padding: 10px 6px;
2594
+ border-radius: 10px;
2595
+ text-align: center;
2596
+ cursor: default;
2597
+ user-select: none;
2598
+ outline: none;
2599
+ }
2600
+
2601
+ .desk-icon-cell[aria-disabled="true"] {
2602
+ opacity: .45;
2603
+ }
2604
+
2605
+ .desk-icon-cell:focus-visible {
2606
+ box-shadow: 0 0 0 2px var(--desk-accent);
2607
+ }
2608
+
2609
+ .desk-icon-glyph {
2610
+ position: relative;
2611
+ display: grid;
2612
+ place-items: center;
2613
+ width: 56px;
2614
+ height: 56px;
2615
+ border-radius: 14px;
2616
+ border: 1px solid var(--desk-rule);
2617
+ background: var(--desk-dock-icon);
2618
+ color: var(--desk-ink);
2619
+ }
2620
+
2621
+ .desk-icon-glyph > svg,
2622
+ .desk-icon-glyph > img {
2623
+ width: 55%;
2624
+ height: 55%;
2625
+ object-fit: contain;
2626
+ }
2627
+
2628
+ .desk-icon-badge {
2629
+ position: absolute;
2630
+ top: -5px;
2631
+ right: -5px;
2632
+ min-width: 18px;
2633
+ padding: 0 5px;
2634
+ border-radius: 9px;
2635
+ background: var(--desk-accent);
2636
+ color: #fff;
2637
+ font-size: 11px;
2638
+ line-height: 18px;
2639
+ }
2640
+
2641
+ .desk-icon-label {
2642
+ max-width: 100%;
2643
+ padding: 1px 6px;
2644
+ border-radius: 5px;
2645
+ font-size: 12.5px;
2646
+ line-height: 1.3;
2647
+ overflow-wrap: anywhere;
2648
+ display: -webkit-box;
2649
+ -webkit-line-clamp: 2;
2650
+ -webkit-box-orient: vertical;
2651
+ overflow: hidden;
2652
+ }
2653
+
2654
+ .desk-icon-subtitle {
2655
+ max-width: 100%;
2656
+ font-size: 11px;
2657
+ color: var(--desk-muted);
2658
+ overflow: hidden;
2659
+ text-overflow: ellipsis;
2660
+ white-space: nowrap;
2661
+ }
2662
+
2663
+ .desk-icon-cell[aria-selected="true"] .desk-icon-glyph {
2664
+ background: color-mix(in srgb, var(--desk-accent) 16%, transparent);
2665
+ }
2666
+
2667
+ .desk-icon-cell[aria-selected="true"] .desk-icon-label {
2668
+ background: var(--desk-accent);
2669
+ color: #fff;
2670
+ }
2671
+
2672
+ .desk-icon-view-empty {
2673
+ display: grid;
2674
+ place-items: center;
2675
+ padding: 32px 16px;
2676
+ color: var(--desk-muted);
2677
+ text-align: center;
2678
+ }
2679
+
2680
+ .desk-icon-drag {
2681
+ display: grid;
2682
+ place-items: center;
2683
+ width: 40px;
2684
+ height: 40px;
2685
+ }
2686
+
2687
+ /* Something carried to the dock: the dock lights up, and a kept item shows where it would go in front. */
2688
+ .desk-dock-item[data-desk-drop][data-ready] {
2689
+ outline: none;
2690
+ background: var(--desk-dock-icon);
2691
+ }
2692
+
2693
+ .desk-dock-item[data-desk-drop][data-over] {
2694
+ box-shadow: -6px 0 0 -2px var(--desk-accent);
2695
+ }
2696
+
2697
+ /* ── The inside of a window: controls that stay, content that scrolls ── */
2698
+
2699
+ .desk-pane {
2700
+ display: flex;
2701
+ flex-direction: column;
2702
+ height: 100%;
2703
+ min-height: 0;
2704
+ }
2705
+
2706
+ .desk-pane-header,
2707
+ .desk-pane-footer {
2708
+ flex: none;
2709
+ }
2710
+
2711
+ /* The only part that scrolls, so the toolbar never leaves with the content. */
2712
+ .desk-pane-body {
2713
+ flex: 1;
2714
+ min-height: 0;
2715
+ overflow: auto;
2716
+ }
2717
+
2718
+ .desk-pane-body:focus-visible {
2719
+ outline: 2px solid var(--desk-accent);
2720
+ outline-offset: -2px;
2721
+ }
2722
+
2723
+ /* A window whose whole content is a pane does not scroll itself: the pane's middle does. */
2724
+ .desk-body:has(> .desk-pane) {
2725
+ overflow: hidden;
2726
+ }
2727
+
2728
+ .desk-toolbar {
2729
+ display: flex;
2730
+ align-items: center;
2731
+ flex-wrap: wrap;
2732
+ gap: var(--desk-space-2);
2733
+ /* One centre line for everything in the row, whatever each control's own height is. */
2734
+ min-height: calc(var(--desk-control-height) + var(--desk-space-2) * 2);
2735
+ padding: var(--desk-space-2) var(--desk-space-3);
2736
+ border-bottom: 1px solid var(--desk-rule);
2737
+ }
2738
+
2739
+ .desk-toolbar > *,
2740
+ .desk-toolbar-trailing > * {
2741
+ flex: none;
2742
+ }
2743
+
2744
+ .desk-toolbar .desk-grow {
2745
+ flex: 1 1 140px;
2746
+ min-width: 0;
2747
+ }
2748
+
2749
+ .desk-toolbar-trailing {
2750
+ margin-left: auto;
2751
+ display: flex;
2752
+ align-items: center;
2753
+ gap: var(--desk-space-2);
2754
+ }
2755
+
2756
+ /* ── An (i): what a view would explain if asked ── */
2757
+
2758
+ .desk-infotip {
2759
+ flex: none;
2760
+ display: inline-grid;
2761
+ place-items: center;
2762
+ width: 18px;
2763
+ height: 18px;
2764
+ padding: 0;
2765
+ border: 1px solid var(--desk-rule);
2766
+ border-radius: 50%;
2767
+ background: none;
2768
+ color: var(--desk-muted);
2769
+ font-family: var(--desk-font);
2770
+ font-size: 11px;
2771
+ font-style: italic;
2772
+ font-weight: 700;
2773
+ line-height: 1;
2774
+ cursor: pointer;
2775
+ }
2776
+
2777
+ .desk-infotip:hover,
2778
+ .desk-infotip[data-open] {
2779
+ border-color: var(--desk-accent);
2780
+ color: var(--desk-accent);
2781
+ }
2782
+
2783
+ .desk-infotip:focus-visible {
2784
+ outline: 2px solid var(--desk-accent);
2785
+ outline-offset: 2px;
2786
+ }
2787
+
2788
+ .desk-infotip-body {
2789
+ max-width: 34ch;
2790
+ font-size: 12.5px;
2791
+ line-height: 1.45;
2792
+ color: var(--desk-muted);
2793
+ }
2794
+
2795
+ .desk-infotip-body > :first-child {
2796
+ margin-top: 0;
2797
+ }
2798
+
2799
+ .desk-infotip-body > :last-child {
2800
+ margin-bottom: 0;
2801
+ }
@@ -13,6 +13,12 @@ export type DeskLayout = 'desktop' | 'fullscreen'
13
13
  export interface DesktopProps {
14
14
  readonly renderWindow: (id: WindowId) => ReactNode
15
15
  readonly title: (id: WindowId) => ReactNode
16
+ /**
17
+ * A quiet line on the trailing side of the title bar, saying what this window is looking at: the
18
+ * request behind it, the file it is editing, how many rows it found. Text, not a control — it
19
+ * gives way to the actions and disappears before the title does when the window is narrow.
20
+ */
21
+ readonly note?: (id: WindowId) => ReactNode
16
22
  /** Window-wide controls on the trailing side of the title bar. One or two; more belongs in a toolbar. */
17
23
  readonly actions?: (id: WindowId) => ReactNode
18
24
  /** Shown when no window is open. */
@@ -70,7 +76,7 @@ const halfFrame = (stage: HTMLElement, side: 'start' | 'end'): Frame => {
70
76
  return { x: side === 'start' ? area.x : area.x + width + area.gap, y: area.y, width, height: area.height }
71
77
  }
72
78
 
73
- export function Desktop({ renderWindow, title, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
79
+ export function Desktop({ renderWindow, title, note, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
74
80
  const desk = useDesk()
75
81
  const state = useDeskState()
76
82
  const stage = useRef<HTMLDivElement>(null)
@@ -150,6 +156,7 @@ export function Desktop({ renderWindow, title, actions, empty, loading, failed,
150
156
  // One window at a time: the rest stay mounted, keeping their state, and simply wait offstage.
151
157
  hidden={mode === 'fullscreen' && window.id !== focused}
152
158
  title={title(window.id)}
159
+ note={note?.(window.id)}
153
160
  actions={actions?.(window.id)}
154
161
  >
155
162
  {/* Memoised on the id and the render function: moving or focusing a window
@@ -184,6 +191,7 @@ interface WindowViewProps {
184
191
  readonly depth: number
185
192
  readonly focused: boolean
186
193
  readonly title: ReactNode
194
+ readonly note?: ReactNode
187
195
  readonly actions?: ReactNode
188
196
  readonly children: ReactNode
189
197
  }
@@ -210,7 +218,7 @@ function reshape(gesture: Gesture, origin: Frame, dx: number, dy: number): Frame
210
218
  }
211
219
  }
212
220
 
213
- function WindowView({ window, layout, hidden, depth, focused, title, actions, children }: WindowViewProps) {
221
+ function WindowView({ window, layout, hidden, depth, focused, title, note, actions, children }: WindowViewProps) {
214
222
  const desk = useDesk()
215
223
  // While dragging, the frame lives here and commits once on release, so a drag
216
224
  // re-renders one window rather than notifying every subscriber per pixel.
@@ -331,6 +339,7 @@ function WindowView({ window, layout, hidden, depth, focused, title, actions, ch
331
339
  <h2 id={titleId} className="desk-title">
332
340
  {title}
333
341
  </h2>
342
+ {note != null && note !== false && <p className="desk-window-note">{note}</p>}
334
343
  {actions && <div className="desk-window-actions">{actions}</div>}
335
344
  </header>
336
345
  <div className="desk-body">{children}</div>