flexdesk 0.17.2 → 0.17.4

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 (49) hide show
  1. package/CHANGELOG.md +100 -63
  2. package/README.md +27 -4517
  3. package/css/base.css +18 -1
  4. package/css/flexdesk.css +18 -1
  5. package/dist/{chunk-4A7SJC3T.js → chunk-RKNFBX5K.js} +15 -4
  6. package/dist/{chunk-4A7SJC3T.js.map → chunk-RKNFBX5K.js.map} +2 -2
  7. package/dist/flexdesk.css +18 -1
  8. package/dist/tiles.js +1 -1
  9. package/dist/widgets.js +59 -26
  10. package/dist/widgets.js.map +2 -2
  11. package/docs/README.md +97 -0
  12. package/docs/best-practices.md +45 -0
  13. package/docs/components/charts.md +211 -0
  14. package/docs/components/command-palette.md +102 -0
  15. package/docs/components/conversation.md +72 -0
  16. package/docs/components/data-table-grid.md +328 -0
  17. package/docs/components/data-table.md +596 -0
  18. package/docs/components/editor.md +22 -0
  19. package/docs/components/flow-canvas-editor.md +482 -0
  20. package/docs/components/flow-editors.md +176 -0
  21. package/docs/components/flow-lane-editor.md +165 -0
  22. package/docs/components/flow-outline-editor.md +176 -0
  23. package/docs/components/forms-and-dialogs.md +294 -0
  24. package/docs/components/input-fields.md +72 -0
  25. package/docs/components/keymap.md +143 -0
  26. package/docs/components/layout-widgets.md +163 -0
  27. package/docs/components/markdown-and-outline.md +106 -0
  28. package/docs/components/menus.md +86 -0
  29. package/docs/components/notifications.md +194 -0
  30. package/docs/components/panels-and-desktops.md +127 -0
  31. package/docs/components/record-list-and-fact-table.md +128 -0
  32. package/docs/components/settings-view.md +112 -0
  33. package/docs/components/shell.md +189 -0
  34. package/docs/components/slide-out-panel.md +68 -0
  35. package/docs/components/tiles.md +210 -0
  36. package/docs/components/wizard.md +54 -0
  37. package/docs/concepts/building-and-packaging.md +33 -0
  38. package/docs/concepts/domain-neutrality.md +110 -0
  39. package/docs/concepts/host-port.md +137 -0
  40. package/docs/concepts/packages-and-entry-points.md +32 -0
  41. package/docs/concepts/peer-dependencies.md +42 -0
  42. package/docs/concepts/state-guard.md +58 -0
  43. package/docs/concepts/styling.md +47 -0
  44. package/docs/quickstart.md +179 -0
  45. package/package.json +2 -1
  46. package/src/ui/components/notification_history.js +17 -24
  47. package/src/ui/components/slide_out_panel.js +14 -3
  48. package/src/ui/notification_center.js +41 -3
  49. package/src/ui/utils/auto_scrollbars.js +16 -2
@@ -0,0 +1,58 @@
1
+ # State guard
2
+
3
+ > **Package:** `@flexdesk/core` · [All docs](../README.md)
4
+
5
+ ## The state guard: who wrote that attribute (0.17.0)
6
+
7
+ `StateGuardService` blocks (`strict`) or reports (`warn`) `data-*` writes that
8
+ are on no allow-list. A module override lets one module write a key nobody
9
+ else may:
10
+
11
+ ```js
12
+ import { StateGuardService, DEFAULT_STATE_GUARD_CONFIG } from '@flexdesk/core';
13
+
14
+ const guard = new StateGuardService({ eventBus, config: { ...DEFAULT_STATE_GUARD_CONFIG, mode: 'strict' } });
15
+ guard.allow('src/app/my_widget.js', ['data-foo']); // a literal substring of the module, or a RegExp
16
+ ```
17
+
18
+ A write is attributed to the frame that made it: the URL or path of the file
19
+ that called `setAttribute`, `removeAttribute` or `dataset`, without line and
20
+ column, whatever your directory layout. A FlexDesk widget's write is the
21
+ widget's, not that of the app module that called the widget. To attribute
22
+ writes further up the stack instead, pass `moduleFramePattern`: the first
23
+ frame from the writer up whose location matches names the module, by the
24
+ pattern's first capture group if it has one.
25
+
26
+ ```js
27
+ config: { ...DEFAULT_STATE_GUARD_CONFIG, moduleFramePattern: /(src\/app\/[^/]+\.js)/ }
28
+ // a write made inside @flexdesk/widgets on behalf of src/app/my_widget.js
29
+ // is now attributed to 'src/app/my_widget.js'
30
+ ```
31
+
32
+ The defaults are `mode: 'warn'`, no module overrides and no frame pattern.
33
+ Before 0.17.0 the module was only found under a `js_new/` path, the defaults
34
+ carried one application's override, and the mode read one application's debug
35
+ global; choose strict mode in your own code.
36
+
37
+ ### The defaults cover FlexDesk's own keys (0.17.0)
38
+
39
+ `DEFAULT_STATE_GUARD_CONFIG` includes every data key FlexDesk's own widgets
40
+ write, so the shell, dialogs, forms, tooltips, tiles and flow editors run under
41
+ `mode: 'strict'` with no violation. The list also carries some keys that
42
+ FlexDesk itself does not write. Add your application's keys on top:
43
+
44
+ ```js
45
+ const guard = new StateGuardService({ eventBus, logger, config: {
46
+ ...DEFAULT_STATE_GUARD_CONFIG,
47
+ allowedDatasetKeys: [...DEFAULT_STATE_GUARD_CONFIG.allowedDatasetKeys, 'order-id'],
48
+ } });
49
+ ```
50
+
51
+ An allow-list key may be spelled either way: `orderId` and `order-id` are the
52
+ same key, in the allow-list and in module overrides
53
+ (`allow(pattern, attributes, { datasetKeys })` and `config.moduleOverrides`)
54
+ alike. camelCase maps to kebab-case as the DOM does for ordinary keys: each
55
+ capital letter becomes `-` plus its lower-case form. So `rowID` is `row-i-d`
56
+ (what `dataset.rowID` writes), not `rowid`; to allow `data-id`, list `id`.
57
+ Before 0.17.0 a camelCase entry never matched, and some entries named keys no
58
+ element carries.
@@ -0,0 +1,47 @@
1
+ # Theming and CSS
2
+
3
+ > **Stylesheets:** `flexdesk/css/tokens`, `flexdesk/css/reset`, `flexdesk/css` · [All docs](../README.md)
4
+
5
+ ## Styling
6
+
7
+ Three stylesheets, and that is the whole CSS contract:
8
+
9
+ | | Specifier | Resolves to | |
10
+ |---|---|---|---|
11
+ | `dist/tokens.css` | `flexdesk/css/tokens` | `css/tokens.css` | Every rule reads `var(--…)` from here. Swap this one file and the shell restyles. |
12
+ | `dist/reset.css` | `flexdesk/css/reset` | `dist/reset.css` | The reset. |
13
+ | `dist/flexdesk.css` | `flexdesk/css` | `dist/flexdesk.css` | The library. Every class is `twm-` prefixed (the original working name; the prefix is stable and not worth a breaking rename). |
14
+
15
+ A bundler, an import-map generator or `require.resolve` reaches each one by its
16
+ specifier, through the package's `exports` map, which closes every other path
17
+ into the package. The map sends `flexdesk/css/tokens` to the source
18
+ `css/tokens.css`, not `dist/tokens.css`. The two are identical in a release,
19
+ since `dist/` is rebuilt in the release commit, but can differ on a working
20
+ checkout. `flexdesk/css/reset` is missing from 0.16.1 and earlier,
21
+ where it fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`.
22
+
23
+ `createDetailHeader` follows the prefix too: a badge is a `.twm-detail-header__badge`
24
+ pill (a badge's `className` is added beside it, to recolour it), and an action is a
25
+ `.twm-btn` — `variant: 'primary'` adds `.twm-btn--primary`, `'danger'` adds
26
+ `.twm-btn--danger` — that also carries `.twm-detail-header__btn` and
27
+ `.twm-detail-header__btn--<variant>`. The unprefixed `detail-header__badge` and
28
+ `detail-header__btn--<variant>` it emitted before, which no FlexDesk rule styled, are
29
+ gone.
30
+
31
+ `@flexdesk/editor`'s components keep the class names they were extracted with, and
32
+ `flexdesk.css` styles them: `SplitPaneContainer` (`.split-pane-container`,
33
+ `.split-pane`, a 4px `.split-pane-divider` with a 10px hit target),
34
+ `EditorPane` (`.editor-pane`, `.editor-pane__body`, `.notebook-editor-container`)
35
+ and `NotebookSearchBar` (`.nb-search-bar*`). `setPanes()` adds `.split-pane` to the
36
+ elements you hand it and leaves their own classes alone (it used to replace them,
37
+ which stripped `.editor-pane` off an `EditorPane`).
38
+
39
+ One exception to the prefix: `HelpModal` (`@flexdesk/widgets`) keeps its older
40
+ `help-` classes (`help-modal-overlay`, `help-modal`, `help-index`, …), and
41
+ `flexdesk.css` styles them — a centred dialog on a dimmed overlay, drawn over
42
+ managed windows. `.help-article` holds your provider's rendered HTML and styles
43
+ its plain elements (headings, code, tables, lists).
44
+
45
+ Icons are rendered by applying the `material-symbols-outlined` class. FlexDesk does
46
+ not ship the font — supply it from Google Fonts or self-host. Without it, icons fall
47
+ back to their text names and nothing breaks.
@@ -0,0 +1,179 @@
1
+ # Quickstart
2
+
3
+ From an empty folder to a shell with a table in it. You need a text editor and a static file server. There is no bundler, no framework and no build step.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install flexdesk
9
+ ```
10
+
11
+ The package ships a prebuilt `dist/`. Copy it into your site (the examples below call the copy `vendor/flexdesk/`) and load it through an import map:
12
+
13
+ ```bash
14
+ mkdir -p vendor && cp -r node_modules/flexdesk/dist vendor/flexdesk
15
+ ```
16
+
17
+ Under a bundler you need none of that. Import the entries as `flexdesk/core`, `flexdesk/host`, `flexdesk/wm`, `flexdesk/widgets` (and `flexdesk/charts`, `flexdesk/tiles`, `flexdesk/editor`, `flexdesk/canvas`, `flexdesk/flow`), and the stylesheets as `flexdesk/css/tokens`, `flexdesk/css/reset` and `flexdesk/css`. The rest of these docs write the entries as `@flexdesk/wm` and so on, which is the name the import map below gives them.
18
+
19
+ ## The page
20
+
21
+ ```html
22
+ <!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8" />
26
+ <title>My first shell</title>
27
+ <script type="importmap">
28
+ { "imports": {
29
+ "@flexdesk/core": "./vendor/flexdesk/core.js",
30
+ "@flexdesk/host": "./vendor/flexdesk/host.js",
31
+ "@flexdesk/wm": "./vendor/flexdesk/wm.js",
32
+ "@flexdesk/widgets": "./vendor/flexdesk/widgets.js"
33
+ } }
34
+ </script>
35
+
36
+ <!-- Optional: the icon font. Without it, icons show their text names. -->
37
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" />
38
+
39
+ <!-- The whole CSS contract, in this order. -->
40
+ <link href="./vendor/flexdesk/tokens.css" rel="stylesheet" />
41
+ <link href="./vendor/flexdesk/reset.css" rel="stylesheet" />
42
+ <link href="./vendor/flexdesk/flexdesk.css" rel="stylesheet" />
43
+
44
+ <style>
45
+ html, body { height: 100%; margin: 0; }
46
+ body {
47
+ background: var(--surface-0);
48
+ color: var(--text-primary);
49
+ font-family: var(--font-family-sans);
50
+ display: flex; flex-direction: column;
51
+ }
52
+ #nav { flex: 0 0 auto; }
53
+ #app { flex: 1 1 0; min-height: 0; position: relative; } /* the shell fills this */
54
+ </style>
55
+ </head>
56
+ <body class="twm-body">
57
+ <div id="nav"></div>
58
+ <div id="app"></div>
59
+ <script type="module" src="./main.js"></script>
60
+ </body>
61
+ </html>
62
+ ```
63
+
64
+ The three stylesheets are the whole CSS contract. `tokens.css` defines every colour, space and font the rules read, `reset.css` is the reset, and `flexdesk.css` is the library. It reads the tokens but does not define them, so without `tokens.css` its colours, spacing and fonts are undefined. See [Theming and CSS](concepts/styling.md#styling).
65
+
66
+ FlexDesk does not ship the icon font (Material Symbols Outlined). Load it from Google Fonts or self-host it. Nothing else breaks without it.
67
+
68
+ ## Hello, shell
69
+
70
+ ```js
71
+ import { createShell, createTaxonomy, createEntityCatalog } from '@flexdesk/wm';
72
+ import { createHost } from '@flexdesk/host';
73
+ import { DataTable } from '@flexdesk/widgets';
74
+
75
+ const store = new Map();
76
+
77
+ const shell = await createShell({
78
+ root: document.getElementById('app'),
79
+
80
+ // YOUR ontology. FlexDesk has none.
81
+ taxonomy: createTaxonomy({
82
+ root: 'cosmos',
83
+ kinds: {
84
+ cosmos: { label: 'Cosmos', icon: 'public', isTopNav: true, sources: ['planet'] },
85
+ planet: { label: 'Planet', icon: 'circle' },
86
+ moon: {
87
+ label: 'Moon',
88
+ // "jupiter/europa" — your naming convention, not the library's.
89
+ ancestors: ({ id }) => {
90
+ const i = String(id).indexOf('/');
91
+ return i > 0 ? [{ kind: 'planet', id: String(id).slice(0, i) }] : [];
92
+ },
93
+ },
94
+ },
95
+ }),
96
+
97
+ entities: createEntityCatalog({ sources: [/* what the palette searches */] }),
98
+ content: { planet: (hostEl, props) => { /* render a tile */ } },
99
+
100
+ // Where persistence goes. An in-memory Map is a perfectly good host.
101
+ host: createHost({
102
+ state: {
103
+ read: async (k) => store.get(k) ?? null,
104
+ write: async (k, v) => (store.set(k, v), true),
105
+ },
106
+ }),
107
+
108
+ // Chrome is ELEMENTS, not selectors. The library never guesses at your markup.
109
+ chrome: { topNav: document.getElementById('nav') },
110
+ });
111
+ ```
112
+
113
+ A working version of exactly this — planets and moons, a mock host, no backend —
114
+ lives in `demo/`.
115
+
116
+ Save that as `main.js`. What each part is:
117
+
118
+ - **`taxonomy`** is your ontology: the kinds of thing your application has and how they nest. FlexDesk has none of its own.
119
+ - **`entities`** is what the command palette (Ctrl+K) searches.
120
+ - **`content`** is one factory per kind. It renders that kind into a tile.
121
+ - **`host`** is where persistence goes. An in-memory `Map` is a perfectly good host; [the host port](concepts/host-port.md#the-host-port) covers the real ones.
122
+ - **`chrome`** is the elements the shell paints into, not selectors. The library never guesses at your markup.
123
+
124
+ ## Put a table in a tile
125
+
126
+ Give the root kind a content factory that mounts a [DataTable](components/data-table.md). `DataTable` is in `@flexdesk/widgets`, which the first snippet already imports.
127
+
128
+ ```js
129
+ const PLANETS = [
130
+ ['Mercury', '0', '0.39'],
131
+ ['Venus', '0', '0.72'],
132
+ ['Earth', '1', '1.00'],
133
+ ['Mars', '2', '1.52'],
134
+ ];
135
+
136
+ const planetsTable = (hostEl) => {
137
+ // A tile body is a flex column. Give the table a box that fills it, in normal flow:
138
+ // absolute positioning would let the table escape the tile.
139
+ const box = document.createElement('div');
140
+ box.style.cssText = 'height:100%; display:flex; flex-direction:column; min-height:0;';
141
+ hostEl.appendChild(box);
142
+ DataTable.mount(box, {
143
+ headers: ['Planet', 'Moons', 'Distance (AU)'],
144
+ rows: PLANETS,
145
+ sortable: true,
146
+ });
147
+ };
148
+
149
+ // …and in createShell({ … }), in place of the planet factory:
150
+ content: {
151
+ cosmos: planetsTable,
152
+ // The three panels ask for content by these names. Without a factory they show a placeholder.
153
+ 'panel:left': (hostEl) => { hostEl.textContent = 'Left panel'; },
154
+ 'panel:right': (hostEl) => { hostEl.textContent = 'Right panel'; },
155
+ 'panel:bottom': (hostEl) => { hostEl.textContent = 'Bottom panel'; },
156
+ },
157
+ ```
158
+
159
+ `cosmos` is the root kind and a top-nav kind, so the shell opens it first. Click a header to sort. The placeholder a panel shows when nothing is registered for it reads "no factory registered yet": the registry working, though it looks like a bug.
160
+
161
+ ## See the full demo
162
+
163
+ `demo/` in the repository is a complete version of this page: planets and moons, an in-memory host, panels, desktops and the command palette, with no backend. It loads `dist/`, and ES modules do not load from `file://`, so serve the repository root with any static file server and open `/demo/`:
164
+
165
+ ```bash
166
+ git clone https://github.com/nor-os/FlexDesk.git
167
+ cd FlexDesk
168
+ python3 -m http.server 8080 # or any static server
169
+ # then open http://localhost:8080/demo/
170
+ ```
171
+
172
+ The other pages in `demo/` are one-component pages: `datatable_070.html`, `records.html`, `flow_canvas.html`, `flow_outline.html`, `flow_lanes.html`, `flow_kit.html` and `flow_modeler.html`.
173
+
174
+ ## Where next
175
+
176
+ - **A component.** Each has a page: [Shell and navigation](components/shell.md), [DataTable](components/data-table.md), [Forms and dialogs](components/forms-and-dialogs.md), and the rest in the [component index](README.md#components).
177
+ - **How the pieces fit.** [Packages and entry points](concepts/packages-and-entry-points.md), the [host port](concepts/host-port.md), [domain neutrality](concepts/domain-neutrality.md) and [theming](concepts/styling.md).
178
+ - **Doing it well.** [Best practices](best-practices.md).
179
+ - **What changed.** The [changelog](../CHANGELOG.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "canvas.js",
29
29
  "flow.js",
30
30
  "css",
31
+ "docs",
31
32
  "README.md",
32
33
  "CHANGELOG.md",
33
34
  "LICENSE",
@@ -2,8 +2,10 @@
2
2
  * NotificationHistory - Bottom bar notification history panel
3
3
  *
4
4
  * Bell icon in the global-bottom-bar that opens a popover listing past notifications.
5
- * Reads from NotificationCenter.history (no modifications to NotificationCenter needed).
6
- * Tracks unread count via lastSeenCount delta and updates a badge on the bell icon.
5
+ * Reads from NotificationCenter.history and follows it through
6
+ * `notificationCenter.subscribe()` (0.17.3): every entry the centre records — a
7
+ * toast from `show()`/`warn()`/... or from the bus — is one unread, until the
8
+ * popover is opened. The bus is not watched for toasts.
7
9
  *
8
10
  * THREE OPTIONS, all additive (FlexDesk STORY-0020); without them it is the bell
9
11
  * it always was:
@@ -34,7 +36,8 @@ export class NotificationHistory {
34
36
 
35
37
  this.triggerEl = null;
36
38
  this.badgeEl = null;
37
- this.lastSeenCount = 0;
39
+ /** Entries recorded since the popover was last opened. */
40
+ this.unreadCount = 0;
38
41
  this._popoverCleanup = null;
39
42
  this._subscriptions = [];
40
43
  }
@@ -80,10 +83,14 @@ export class NotificationHistory {
80
83
  return;
81
84
  }
82
85
 
83
- // Sync initial count so existing history doesn't show as unread
84
- this.lastSeenCount = this.notificationCenter?.history?.length ?? 0;
85
-
86
- this.#subscribeEvents();
86
+ // What is in the history at mount is not unread.
87
+ this.unreadCount = 0;
88
+ const unsubscribe = this.notificationCenter?.subscribe?.(() => {
89
+ this.unreadCount += 1;
90
+ this.#updateBadge();
91
+ });
92
+ if (typeof unsubscribe === 'function') this._subscriptions.push(unsubscribe);
93
+ this.#updateBadge();
87
94
  }
88
95
 
89
96
  unmount() {
@@ -99,20 +106,6 @@ export class NotificationHistory {
99
106
  this.badgeEl = null;
100
107
  }
101
108
 
102
- #subscribeEvents() {
103
- if (!this.eventBus) return;
104
-
105
- // Update badge whenever a new notification comes in
106
- const badgeUpdate = () => queueMicrotask(() => this.#updateBadge());
107
- const events = ['toast:show', 'notification:show', 'log:entry',
108
- 'workspace:save:failed', 'workspace:import:failed'];
109
-
110
- events.forEach(name => {
111
- const handle = this.eventBus.on(name, badgeUpdate);
112
- this._subscriptions.push(() => handle?.dispose?.());
113
- });
114
- }
115
-
116
109
  /** The Alerts tab, asked for on the bus — or the embedder's own verb. */
117
110
  #openAlerts() {
118
111
  if (this.onOpen) {
@@ -126,7 +119,7 @@ export class NotificationHistory {
126
119
  const historyLen = this.notificationCenter?.history?.length ?? 0;
127
120
  const unread = this.alertLog
128
121
  ? this.alertLog.unreadCount()
129
- : Math.max(0, historyLen - this.lastSeenCount);
122
+ : Math.min(this.unreadCount, historyLen);
130
123
 
131
124
  if (!this.badgeEl) return;
132
125
 
@@ -164,7 +157,7 @@ export class NotificationHistory {
164
157
  this.#closePanel();
165
158
 
166
159
  // Mark all as seen
167
- this.lastSeenCount = this.notificationCenter?.history?.length ?? 0;
160
+ this.unreadCount = 0;
168
161
  this.#updateBadge();
169
162
 
170
163
  const panel = document.createElement('div');
@@ -197,7 +190,7 @@ export class NotificationHistory {
197
190
  header.querySelector('.twm-notification-history__clear-btn')
198
191
  .addEventListener('click', () => {
199
192
  this.notificationCenter.history.splice(0);
200
- this.lastSeenCount = 0;
193
+ this.unreadCount = 0;
201
194
  this.#updateBadge();
202
195
  this.#renderList(list);
203
196
  });
@@ -58,7 +58,7 @@ export class SlideOutPanel extends ComponentBase {
58
58
  header.className = 'twm-slide-out-panel__header';
59
59
 
60
60
  this.#headerIcon = document.createElement('span');
61
- this.#headerIcon.className = 'material-symbols-outlined';
61
+ this.#headerIcon.className = 'twm-slide-out-panel__icon material-symbols-outlined';
62
62
 
63
63
  this.#headerTitle = document.createElement('span');
64
64
  this.#headerTitle.className = 'twm-slide-out-panel__title';
@@ -206,13 +206,24 @@ export class SlideOutPanel extends ComponentBase {
206
206
  * engine that predates it), and the width subtracted is the panel's
207
207
  * laid-out one, which `min-width` may hold above the requested
208
208
  * width. Rounding happens in screen pixels, so at 100% the output is
209
- * exactly what it always was. */
209
+ * exactly what it always was.
210
+ *
211
+ * NEVER WIDER THAN THE HOST (BUG-0035). The right edge is pinned, so a
212
+ * panel wider than its host (700px by default, `min-width: 400px`)
213
+ * reached past the host's left edge across the neighbouring pane. The
214
+ * host's width, in the panel's CSS pixels, goes on the panel as
215
+ * `--twm-slide-out-host-w`, which the stylesheet uses as `max-width`
216
+ * and as the ceiling of the 400px `min-width`; it is set BEFORE the
217
+ * width is read so the read sees the capped layout, and the width is
218
+ * capped here too for an engine that lays nothing out. */
210
219
  #trackGeometry = () => {
211
220
  if (!this.#open || !this.#panel || !this.#container) return;
212
221
  const r = this.#container.getBoundingClientRect();
213
222
  const zoom = this.#panel.currentCSSZoom || 1;
214
- const width = this.#panel.offsetWidth || this.#width;
223
+ const hostWidth = r.width / zoom;
215
224
  const s = this.#panel.style;
225
+ s.setProperty('--twm-slide-out-host-w', `${hostWidth}px`);
226
+ const width = Math.min(this.#panel.offsetWidth || this.#width, hostWidth);
216
227
  s.position = 'fixed';
217
228
  s.top = `${Math.round(r.top) / zoom}px`;
218
229
  s.left = `${Math.round(r.right - width * zoom) / zoom}px`;
@@ -27,6 +27,14 @@
27
27
  * collapsed state and a hovered button survive. A running progress toast is
28
28
  * never held behind "+N more" (and never dropped by 'drop'); it counts toward
29
29
  * `maxVisible`, so it takes the place of the newest other toast.
30
+ *
31
+ * History subscribers (0.17.3)
32
+ * ----------------------------
33
+ * `history` holds one entry per toast, whichever way it came (`show()`, the
34
+ * `warn()`/`error()`/... helpers, `toast:show`, `notification:show`,
35
+ * `log:entry`). A toast that leaves marks its own entry `dismissed` with a
36
+ * `reason`; it adds none. `subscribe(fn)` calls `fn(entry, center)` for each
37
+ * entry added, so a history view follows the centre rather than the bus.
30
38
  */
31
39
  import { ComponentBase } from './base/component_base.js';
32
40
  import { getSetting } from '../core/settings.js';
@@ -55,6 +63,7 @@ const SUPPRESSED_NAMESPACE_TITLES = new Set(['bootstrap', 'app']);
55
63
  export class NotificationCenter extends ComponentBase {
56
64
  #items;
57
65
  #summaryEl;
66
+ #historyListeners;
58
67
 
59
68
  constructor({
60
69
  eventBus,
@@ -90,6 +99,7 @@ export class NotificationCenter extends ComponentBase {
90
99
  /** id -> the drawn toast's nodes, for the toasts on screen. */
91
100
  this.#items = new Map();
92
101
  this.#summaryEl = null;
102
+ this.#historyListeners = new Set();
93
103
 
94
104
  this.container = null;
95
105
  this.rootEl = null;
@@ -151,6 +161,7 @@ export class NotificationCenter extends ComponentBase {
151
161
  dispose() {
152
162
  this.#clearTimers();
153
163
  this.#unsubscribeEvents();
164
+ this.#historyListeners.clear();
154
165
  // Give the toast helpers back to the console — unless someone has
155
166
  // pointed them at another bus since this centre mounted.
156
167
  if (this.eventBus && getToastBus() === this.eventBus) setToastBus(null);
@@ -190,7 +201,7 @@ export class NotificationCenter extends ComponentBase {
190
201
  if (idx !== -1) {
191
202
  const [removed] = this.notifications.splice(idx, 1);
192
203
  this.#stopTimer(removed.id);
193
- this.#recordHistory({ ...removed, dismissed: true, reason: 'overflow' });
204
+ this.#recordDismissal(removed, 'overflow');
194
205
  }
195
206
  }
196
207
 
@@ -207,13 +218,13 @@ export class NotificationCenter extends ComponentBase {
207
218
  const h = this.held.findIndex((item) => item.id === notificationId);
208
219
  if (h === -1) return false;
209
220
  const [gone] = this.held.splice(h, 1);
210
- this.#recordHistory({ ...gone, dismissed: true, reason });
221
+ this.#recordDismissal(gone, reason);
211
222
  this.#render();
212
223
  return true;
213
224
  }
214
225
  const [removed] = this.notifications.splice(idx, 1);
215
226
  this.#stopTimer(notificationId);
216
- this.#recordHistory({ ...removed, dismissed: true, reason });
227
+ this.#recordDismissal(removed, reason);
217
228
  // A place on screen came free: the oldest held-back toast takes it.
218
229
  this.#rebalance();
219
230
  this.#render();
@@ -475,12 +486,39 @@ export class NotificationCenter extends ComponentBase {
475
486
  this.activeTimers.clear();
476
487
  }
477
488
 
489
+ /**
490
+ * Hear of every entry added to `history`, by `show()`, `warn()`/`error()`/
491
+ * `info()`/`success()` and the bus alike: `listener(entry, center)`, once
492
+ * per toast, after the entry is in `history`. A dismissal changes the
493
+ * toast's own entry and is not an entry of its own. Answers the unsubscribe.
494
+ */
495
+ subscribe(listener) {
496
+ if (typeof listener !== 'function') return () => {};
497
+ this.#historyListeners.add(listener);
498
+ return () => { this.#historyListeners.delete(listener); };
499
+ }
500
+
478
501
  #recordHistory(entry) {
479
502
  if (entry.skipHistory) return;
480
503
  this.history.push(entry);
481
504
  if (this.history.length > this.historyLimit) {
482
505
  this.history.shift();
483
506
  }
507
+ [...this.#historyListeners].forEach((listener) => {
508
+ try {
509
+ listener(entry, this);
510
+ } catch (err) {
511
+ this.logger?.warn?.('twm-notification-center', 'History listener failed', { err });
512
+ }
513
+ });
514
+ }
515
+
516
+ /** A toast that left marks its own entry: one toast, one entry. */
517
+ #recordDismissal(notification, reason) {
518
+ if (notification.skipHistory) return;
519
+ const at = this.history.findLastIndex((entry) => entry.id === notification.id);
520
+ if (at === -1) return;
521
+ this.history[at] = { ...notification, dismissed: true, reason };
484
522
  }
485
523
 
486
524
  /** A progress toast that has not been given an outcome yet. */
@@ -58,12 +58,15 @@ export function thumbGeometry(scrollSize, clientSize, scrollPos, trackLen) {
58
58
  *
59
59
  * @param {Element} el
60
60
  * @param {Element[][]} stacks what `elementsFromPoint` gave at each point,
61
- * topmost first. The overlay layer's own bars are skipped.
61
+ * topmost first. The overlay layer's own bars are skipped, and so are the
62
+ * window's `.twm-resize-handle` edge strips (BUG-0036): they lie over every
63
+ * pane flush against a window edge and are chrome, not cover.
62
64
  * @returns {boolean}
63
65
  */
64
66
  export function barCovered(el, stacks) {
67
+ const passThrough = (h) => h.closest?.('.twm-autoscroll-layer') || h.closest?.('.twm-resize-handle');
65
68
  return stacks.some((stack) => {
66
- const top = (stack || []).find((h) => !h.closest?.('.twm-autoscroll-layer'));
69
+ const top = (stack || []).find((h) => !passThrough(h));
67
70
  return Boolean(top) && top !== el && !el.contains(top);
68
71
  });
69
72
  }
@@ -277,6 +280,16 @@ export function installAutoScrollbars(root = document.body, { exclude = null } =
277
280
  cancelAnimationFrame(raf);
278
281
  raf = requestAnimationFrame(() => { for (const el of managed.keys()) place(el); });
279
282
  };
283
+ // An element taken out of the page (a managed window closed) takes its
284
+ // bars with it at once. They live in the layer, not in the element, so
285
+ // otherwise they would stay on screen through the hide delay and fade.
286
+ const sweep = new MutationObserver((mutations) => {
287
+ if (!managed.size || !mutations.some((m) => m.removedNodes.length)) return;
288
+ for (const el of [...managed.keys()]) {
289
+ if (!el.isConnected) { drop(el); hoverChain.delete(el); }
290
+ }
291
+ });
292
+ sweep.observe(root, { childList: true, subtree: true });
280
293
 
281
294
  doc.addEventListener('mouseover', onOver, true);
282
295
  doc.addEventListener('mouseout', onLeaveWindow, true);
@@ -293,6 +306,7 @@ export function installAutoScrollbars(root = document.body, { exclude = null } =
293
306
  doc.removeEventListener('scroll', onScroll, true);
294
307
  win.removeEventListener('resize', onResize);
295
308
  doc.removeEventListener('focusin', onResize, true);
309
+ sweep.disconnect();
296
310
  for (const el of [...managed.keys()]) drop(el);
297
311
  root.classList.remove(ROOT_CLASS);
298
312
  },