flexdesk 0.3.0 → 0.4.1

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.
package/README.md CHANGED
@@ -104,6 +104,41 @@ const shell = await createShell({
104
104
  A working version of exactly this — planets and moons, a mock host, no backend —
105
105
  lives in `demo/`.
106
106
 
107
+ ### Opt-in shell features
108
+
109
+ These change what the user sees, so all of them are off until you ask:
110
+
111
+ ```js
112
+ await createShell({
113
+ // …everything above…
114
+ snapPromotion: true, // drag a floating window onto a tile: it snaps, and docks back in
115
+ promoteInPlace: true, // a window floated out of a tile stays inside that tile's pane
116
+ backToOpenList: true, // Back in a record opened beside its list closes onto that list
117
+ chrome: {
118
+ topNav: document.getElementById('nav'),
119
+ zoom: document.getElementById('status-bar'), // content zoom, 50–200%
120
+ },
121
+ });
122
+ ```
123
+
124
+ **`chrome.zoom`** paints a − / track / + / readout control into the element you
125
+ pass, restores the saved zoom through the host's `state` capability, and scales
126
+ the content of tiles and floating windows. It deliberately scales *content only*
127
+ — never the root, a tile's frame or a window's frame — because CSS `zoom`
128
+ establishes a scaled coordinate space and every window drag, resize and snap
129
+ measures in real pixels. `shell.chrome.zoom` exposes `get()` and `set(percent)`
130
+ for a settings pane. An embedder that builds its own `WindowManager` instead of
131
+ calling `createShell` mounts the same control with `mountZoomControl(el, { root,
132
+ host })`.
133
+
134
+ **`backToOpenList`** is for apps that open a record from a list in a new tab.
135
+ Without it, Back in that record has no history to pop, so it walks the taxonomy
136
+ up and rewrites the record into its parent, and the tile now shows the list
137
+ twice. With it, Back closes the record tab and activates the list it came from:
138
+ the nearest tab to the left, else to the right, that is not a record (no
139
+ `props.id`) and belongs to the same top-nav section. Per-tab history still comes
140
+ first, and a record with no such list beside it still walks up in place.
141
+
107
142
  ## The host port
108
143
 
109
144
  FlexDesk never touches `pywebview`, `fetch`, `localStorage` or the filesystem. It
package/css/base.css CHANGED
@@ -4740,3 +4740,71 @@ button.twm-about-dialog__btn--close:focus {
4740
4740
  * `position: fixed` descendants, which would clip every dropdown the grid opens
4741
4741
  * on purpose. */
4742
4742
  .twm-root { position: relative; }
4743
+
4744
+ /* ── C31. Content zoom ─────────────────────────────────────────────
4745
+ Applied by zoom.js as `--twm-zoom` plus `.twm-zoomed` on the shell
4746
+ ROOT. The class is only set away from 100%, so an unzoomed shell has
4747
+ no `zoom` declaration anywhere: `zoom: 1` is not a no-op, it still
4748
+ establishes a scaled coordinate space.
4749
+
4750
+ CONTENT SURFACES ONLY. A tile's body and a promoted window's content
4751
+ sit below every piece of geometry the window drag, resize and C15
4752
+ snap probe read. The root, a leaf wrap, tile chrome, tab bars and a
4753
+ window frame are deliberately absent: a contained window lives in a
4754
+ leaf wrap and is re-parented to the root while it is dragged, so
4755
+ zooming either would drift every drag by the zoom factor. See zoom.js. */
4756
+ .twm-zoomed .twm-leaf__body,
4757
+ .twm-zoomed .twm-window-content { zoom: var(--twm-zoom, 1); }
4758
+
4759
+ /* The control. Sized against the bar it sits in, not the zoom — it is
4760
+ chrome, and it is the one thing that must stay reachable at 200%. */
4761
+ .twm-zoom {
4762
+ display: inline-flex;
4763
+ align-items: center;
4764
+ gap: 3px;
4765
+ height: 100%;
4766
+ padding: 0 8px;
4767
+ border-left: 1px solid var(--surface-3);
4768
+ user-select: none;
4769
+ }
4770
+ .twm-zoom__step {
4771
+ display: inline-flex;
4772
+ align-items: center;
4773
+ justify-content: center;
4774
+ width: 15px;
4775
+ height: 15px;
4776
+ padding: 0;
4777
+ border: none;
4778
+ border-radius: 2px;
4779
+ background: none;
4780
+ color: var(--text-muted);
4781
+ font: inherit;
4782
+ font-size: var(--font-size-sm);
4783
+ line-height: 1;
4784
+ cursor: pointer;
4785
+ }
4786
+ .twm-zoom__step:hover { background: var(--surface-3); color: var(--text-primary); }
4787
+ /* A short track on purpose: it sits between two buttons in a thin bar,
4788
+ and the readout — not the thumb position — answers "where am I". */
4789
+ .twm-zoom__slider {
4790
+ width: 78px;
4791
+ height: 12px;
4792
+ margin: 0;
4793
+ cursor: pointer;
4794
+ accent-color: var(--accent-bright);
4795
+ }
4796
+ /* Tabular numerals, so the readout does not jiggle between 95% and 100%
4797
+ while the thumb moves. */
4798
+ .twm-zoom__value {
4799
+ min-width: 34px;
4800
+ padding: 0;
4801
+ border: none;
4802
+ background: none;
4803
+ color: var(--text-muted);
4804
+ font-family: var(--font-family-mono);
4805
+ font-size: var(--font-size-xs);
4806
+ font-variant-numeric: tabular-nums;
4807
+ text-align: right;
4808
+ cursor: pointer;
4809
+ }
4810
+ .twm-zoom__value:hover { color: var(--text-primary); }
package/css/flexdesk.css CHANGED
@@ -7088,3 +7088,71 @@ tr:hover .twm-row-action { opacity: 1; }
7088
7088
  .twm-top-nav__label {
7089
7089
  /* hidden on narrow viewports; icon alone is enough */
7090
7090
  }
7091
+
7092
+ /* ── C31. Content zoom ─────────────────────────────────────────────
7093
+ Applied by zoom.js as `--twm-zoom` plus `.twm-zoomed` on the shell
7094
+ ROOT. The class is only set away from 100%, so an unzoomed shell has
7095
+ no `zoom` declaration anywhere: `zoom: 1` is not a no-op, it still
7096
+ establishes a scaled coordinate space.
7097
+
7098
+ CONTENT SURFACES ONLY. A tile's body and a promoted window's content
7099
+ sit below every piece of geometry the window drag, resize and C15
7100
+ snap probe read. The root, a leaf wrap, tile chrome, tab bars and a
7101
+ window frame are deliberately absent: a contained window lives in a
7102
+ leaf wrap and is re-parented to the root while it is dragged, so
7103
+ zooming either would drift every drag by the zoom factor. See zoom.js. */
7104
+ .twm-zoomed .twm-leaf__body,
7105
+ .twm-zoomed .twm-window-content { zoom: var(--twm-zoom, 1); }
7106
+
7107
+ /* The control. Sized against the bar it sits in, not the zoom — it is
7108
+ chrome, and it is the one thing that must stay reachable at 200%. */
7109
+ .twm-zoom {
7110
+ display: inline-flex;
7111
+ align-items: center;
7112
+ gap: 3px;
7113
+ height: 100%;
7114
+ padding: 0 8px;
7115
+ border-left: 1px solid var(--surface-3);
7116
+ user-select: none;
7117
+ }
7118
+ .twm-zoom__step {
7119
+ display: inline-flex;
7120
+ align-items: center;
7121
+ justify-content: center;
7122
+ width: 15px;
7123
+ height: 15px;
7124
+ padding: 0;
7125
+ border: none;
7126
+ border-radius: 2px;
7127
+ background: none;
7128
+ color: var(--text-muted);
7129
+ font: inherit;
7130
+ font-size: var(--font-size-sm);
7131
+ line-height: 1;
7132
+ cursor: pointer;
7133
+ }
7134
+ .twm-zoom__step:hover { background: var(--surface-3); color: var(--text-primary); }
7135
+ /* A short track on purpose: it sits between two buttons in a thin bar,
7136
+ and the readout — not the thumb position — answers "where am I". */
7137
+ .twm-zoom__slider {
7138
+ width: 78px;
7139
+ height: 12px;
7140
+ margin: 0;
7141
+ cursor: pointer;
7142
+ accent-color: var(--accent-bright);
7143
+ }
7144
+ /* Tabular numerals, so the readout does not jiggle between 95% and 100%
7145
+ while the thumb moves. */
7146
+ .twm-zoom__value {
7147
+ min-width: 34px;
7148
+ padding: 0;
7149
+ border: none;
7150
+ background: none;
7151
+ color: var(--text-muted);
7152
+ font-family: var(--font-family-mono);
7153
+ font-size: var(--font-size-xs);
7154
+ font-variant-numeric: tabular-nums;
7155
+ text-align: right;
7156
+ cursor: pointer;
7157
+ }
7158
+ .twm-zoom__value:hover { color: var(--text-primary); }
package/dist/flexdesk.css CHANGED
@@ -7088,3 +7088,71 @@ tr:hover .twm-row-action { opacity: 1; }
7088
7088
  .twm-top-nav__label {
7089
7089
  /* hidden on narrow viewports; icon alone is enough */
7090
7090
  }
7091
+
7092
+ /* ── C31. Content zoom ─────────────────────────────────────────────
7093
+ Applied by zoom.js as `--twm-zoom` plus `.twm-zoomed` on the shell
7094
+ ROOT. The class is only set away from 100%, so an unzoomed shell has
7095
+ no `zoom` declaration anywhere: `zoom: 1` is not a no-op, it still
7096
+ establishes a scaled coordinate space.
7097
+
7098
+ CONTENT SURFACES ONLY. A tile's body and a promoted window's content
7099
+ sit below every piece of geometry the window drag, resize and C15
7100
+ snap probe read. The root, a leaf wrap, tile chrome, tab bars and a
7101
+ window frame are deliberately absent: a contained window lives in a
7102
+ leaf wrap and is re-parented to the root while it is dragged, so
7103
+ zooming either would drift every drag by the zoom factor. See zoom.js. */
7104
+ .twm-zoomed .twm-leaf__body,
7105
+ .twm-zoomed .twm-window-content { zoom: var(--twm-zoom, 1); }
7106
+
7107
+ /* The control. Sized against the bar it sits in, not the zoom — it is
7108
+ chrome, and it is the one thing that must stay reachable at 200%. */
7109
+ .twm-zoom {
7110
+ display: inline-flex;
7111
+ align-items: center;
7112
+ gap: 3px;
7113
+ height: 100%;
7114
+ padding: 0 8px;
7115
+ border-left: 1px solid var(--surface-3);
7116
+ user-select: none;
7117
+ }
7118
+ .twm-zoom__step {
7119
+ display: inline-flex;
7120
+ align-items: center;
7121
+ justify-content: center;
7122
+ width: 15px;
7123
+ height: 15px;
7124
+ padding: 0;
7125
+ border: none;
7126
+ border-radius: 2px;
7127
+ background: none;
7128
+ color: var(--text-muted);
7129
+ font: inherit;
7130
+ font-size: var(--font-size-sm);
7131
+ line-height: 1;
7132
+ cursor: pointer;
7133
+ }
7134
+ .twm-zoom__step:hover { background: var(--surface-3); color: var(--text-primary); }
7135
+ /* A short track on purpose: it sits between two buttons in a thin bar,
7136
+ and the readout — not the thumb position — answers "where am I". */
7137
+ .twm-zoom__slider {
7138
+ width: 78px;
7139
+ height: 12px;
7140
+ margin: 0;
7141
+ cursor: pointer;
7142
+ accent-color: var(--accent-bright);
7143
+ }
7144
+ /* Tabular numerals, so the readout does not jiggle between 95% and 100%
7145
+ while the thumb moves. */
7146
+ .twm-zoom__value {
7147
+ min-width: 34px;
7148
+ padding: 0;
7149
+ border: none;
7150
+ background: none;
7151
+ color: var(--text-muted);
7152
+ font-family: var(--font-family-mono);
7153
+ font-size: var(--font-size-xs);
7154
+ font-variant-numeric: tabular-nums;
7155
+ text-align: right;
7156
+ cursor: pointer;
7157
+ }
7158
+ .twm-zoom__value:hover { color: var(--text-primary); }
package/dist/wm.js CHANGED
@@ -588,7 +588,7 @@ var TileTree = class _TileTree {
588
588
  0,
589
589
  Math.min(n.tabs.length - 1, saved.activeTabIdx || 0)
590
590
  );
591
- const wantsTarget = target.props?.id != null || target.kind !== targetTopNav;
591
+ const wantsTarget = target.props?.id != null || target.kind !== targetTopNav || Object.keys(target.props || {}).length > 0;
592
592
  if (wantsTarget) {
593
593
  const wantId = target.props?.id != null;
594
594
  const matchIdx = n.tabs.findIndex((t) => t.kind === target.kind && (!wantId || String(t.props?.id ?? "") === String(target.props.id)));
@@ -757,9 +757,11 @@ var TileTree = class _TileTree {
757
757
  };
758
758
  n.tabs = Array.isArray(n.tabs) ? n.tabs : [];
759
759
  n.tabs.push(tab);
760
- n.activeTabIdx = n.tabs.length - 1;
761
- _syncActiveTab(n);
762
- return n.activeTabIdx;
760
+ if (!opts.background) {
761
+ n.activeTabIdx = n.tabs.length - 1;
762
+ _syncActiveTab(n);
763
+ }
764
+ return n.tabs.length - 1;
763
765
  }
764
766
  /** Switch the active tab on a leaf. No-op if `idx` is out of range. */
765
767
  setActiveLeafTab(leafId, idx) {
@@ -3976,7 +3978,8 @@ var WindowManager = class _WindowManager {
3976
3978
  panelDefaults = null,
3977
3979
  snapPromotion = false,
3978
3980
  promoteInPlace = false,
3979
- tabLayout = null
3981
+ tabLayout = null,
3982
+ backToOpenList = false
3980
3983
  }) {
3981
3984
  if (!taxonomy) throw new Error("WindowManager: a taxonomy is required");
3982
3985
  if (!content || typeof content.mount !== "function") {
@@ -4002,6 +4005,7 @@ var WindowManager = class _WindowManager {
4002
4005
  this.panelDefaults = panelDefaults || null;
4003
4006
  this.snapPromotion = !!snapPromotion;
4004
4007
  this.promoteInPlace = !!promoteInPlace;
4008
+ this.backToOpenList = !!backToOpenList;
4005
4009
  this._snapCtl = null;
4006
4010
  this.desktops = new DesktopManager({
4007
4011
  seed: this._rootLeaf,
@@ -4184,6 +4188,7 @@ var WindowManager = class _WindowManager {
4184
4188
  this._notifyChange("navigate-back");
4185
4189
  return;
4186
4190
  }
4191
+ if (this.backToOpenList && this._backToOpenList(id)) return;
4187
4192
  const leaf = tree.get(id);
4188
4193
  if (!leaf?.content) return;
4189
4194
  const { kind, props } = leaf.content;
@@ -4228,6 +4233,64 @@ var WindowManager = class _WindowManager {
4228
4233
  /** Navigate inside the current tab — preserves every other tab in
4229
4234
  * the leaf. Used by Backspace + breadcrumb segments + anything
4230
4235
  * else that should walk WITHIN the tile rather than reset it. */
4236
+ /**
4237
+ * C32. BACK FROM AN OPENED RECORD RETURNS TO THE LIST, IT DOES NOT MAKE ONE.
4238
+ *
4239
+ * The gesture this is for: a list tab, a record opened from it into a NEW
4240
+ * tab, and Backspace in that record. With nothing left in the record tab's
4241
+ * own history, step (3) of `navigateBack` walks the taxonomy up and REWRITES
4242
+ * the record tab into its parent — so the tile ends up showing the list
4243
+ * twice, once in the tab you started from and once in the tab you just left.
4244
+ * The user's model is a browser's: you came from the list, the list is still
4245
+ * open, so going back means closing this and being on the list again.
4246
+ *
4247
+ * So: when the active tab is an ENTITY — its props carry an `id`, the same
4248
+ * test `swapToPage` uses for "the caller asked for a specific entity" — look
4249
+ * for an open tab of the SAME section that is NOT one. That is the list or
4250
+ * landing page the record belongs to. Close the record and activate it.
4251
+ *
4252
+ * NEAREST TO THE LEFT FIRST. New tabs are appended at the end, so the tab a
4253
+ * record was opened from sits to its left; when several lists of the section
4254
+ * are open, the nearest one on that side is the one it most plausibly came
4255
+ * from. The right side is searched only after the left has nothing.
4256
+ *
4257
+ * DECLINES, and leaves step (3) to walk up in place, whenever there is no
4258
+ * such tab: a record that is the tile's only tab, or one whose section has no
4259
+ * list open. That keeps the one guarantee the whole rule exists for — Back
4260
+ * never leaves two copies of a list — without ever closing a record into a
4261
+ * tile that has nothing of its own to show.
4262
+ *
4263
+ * Matching is by SECTION (`topNavFor`) and not by kind, because the list a
4264
+ * record was opened from is often not the record's taxonomy parent: a root
4265
+ * landing page that renders the section, or a list that a flattened taxonomy
4266
+ * files beside the record rather than above it.
4267
+ *
4268
+ * @returns {boolean} whether it closed a tab
4269
+ */
4270
+ _backToOpenList(leafId) {
4271
+ const tree = this._tree();
4272
+ const leaf = tree.get(leafId);
4273
+ if (!leaf || leaf.kind !== "leaf") return false;
4274
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
4275
+ if (tabs.length < 2) return false;
4276
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
4277
+ const active = tabs[activeIdx];
4278
+ if (!_isEntityTab(active)) return false;
4279
+ const section = this.taxonomy.topNavFor(active.kind);
4280
+ if (!section) return false;
4281
+ const isList = (t) => t && !_isEntityTab(t) && this.taxonomy.topNavFor(t.kind) === section;
4282
+ let target = -1;
4283
+ for (let i = activeIdx - 1; i >= 0 && target < 0; i--) if (isList(tabs[i])) target = i;
4284
+ for (let i = activeIdx + 1; i < tabs.length && target < 0; i++) if (isList(tabs[i])) target = i;
4285
+ if (target < 0) return false;
4286
+ tree.removeLeafTab(leafId, activeIdx);
4287
+ tree.setActiveLeafTab(leafId, target > activeIdx ? target - 1 : target);
4288
+ tree.focus(leafId);
4289
+ this.renderer.render();
4290
+ this._persist();
4291
+ this._notifyChange("navigate-back-close");
4292
+ return true;
4293
+ }
4231
4294
  navigateActiveTab(leafId, kind, props = {}) {
4232
4295
  const tree = this._tree();
4233
4296
  const leaf = tree.get(leafId);
@@ -6528,6 +6591,11 @@ var WindowManager = class _WindowManager {
6528
6591
  * `opts.transient` — the appended tab is not persisted/restored
6529
6592
  * (e.g. an add-row form). Only meaningful with
6530
6593
  * `newTab:true`.
6594
+ * `opts.background` — with `newTab`, append the tab WITHOUT switching to
6595
+ * it or focusing its tile. "Open in a background tab"
6596
+ * means the page you are reading stays in front;
6597
+ * without it the tab arrives and takes the screen,
6598
+ * which is what an ordinary click already does.
6531
6599
  *
6532
6600
  * Back-compat: the legacy `opts.target` enum still works and maps
6533
6601
  * onto the axes — 'auto'→origin, 'tab'→origin+newTab,
@@ -6538,6 +6606,7 @@ var WindowManager = class _WindowManager {
6538
6606
  * `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
6539
6607
  navigate(kind, props = {}, opts = {}) {
6540
6608
  const { ctx = null, transient = false } = opts;
6609
+ const background = !!opts.background;
6541
6610
  let { dest = "main", newTab = false } = opts;
6542
6611
  if (opts.target != null) {
6543
6612
  switch (opts.target) {
@@ -6573,9 +6642,9 @@ var WindowManager = class _WindowManager {
6573
6642
  }
6574
6643
  if (dest === "window") return this._navigateWindow(kind, props);
6575
6644
  if (dest === "main") {
6576
- return newTab ? this.openInTabInPrimary(kind, props, transient) : this.openInPrimary(kind, props);
6645
+ return newTab ? this.openInTabInPrimary(kind, props, transient, background) : this.openInPrimary(kind, props);
6577
6646
  }
6578
- return newTab ? this._navigateTab(ctx, kind, props, transient) : this._navigateAuto(ctx, kind, props);
6647
+ return newTab ? this._navigateTab(ctx, kind, props, transient, background) : this._navigateAuto(ctx, kind, props);
6579
6648
  }
6580
6649
  _navigateAuto(ctx, kind, props) {
6581
6650
  if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
@@ -6593,12 +6662,12 @@ var WindowManager = class _WindowManager {
6593
6662
  }
6594
6663
  this.openInPrimary(kind, props);
6595
6664
  }
6596
- _navigateTab(ctx, kind, props, transient = false) {
6665
+ _navigateTab(ctx, kind, props, transient = false, background = false) {
6597
6666
  if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
6598
6667
  this.openInWindow(ctx.windowId, kind, props);
6599
6668
  return;
6600
6669
  }
6601
- this.openInTabFromContext(ctx || {}, kind, props, transient);
6670
+ this.openInTabFromContext(ctx || {}, kind, props, transient, background);
6602
6671
  }
6603
6672
  /** Spawn a fresh ManagedWindow with the requested content. No
6604
6673
  * source leaf — closing the window just disposes the content. */
@@ -6719,7 +6788,7 @@ var WindowManager = class _WindowManager {
6719
6788
  * Mirrors `openFromContext` (windowed / split-leaf / primary
6720
6789
  * routing) but uses `appendLeafTab` so the existing content
6721
6790
  * stays in place as a tab. */
6722
- openInTabFromContext(ctx, kind, props = {}, transient = false) {
6791
+ openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
6723
6792
  if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
6724
6793
  this.openInWindow(ctx.windowId, kind, props);
6725
6794
  return;
@@ -6733,8 +6802,13 @@ var WindowManager = class _WindowManager {
6733
6802
  this.openInPrimary(kind, props);
6734
6803
  return;
6735
6804
  }
6736
- tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
6737
- tree.focus(leafId);
6805
+ tree.appendLeafTab(
6806
+ leafId,
6807
+ { kind, props },
6808
+ _tabTitle(kind, props),
6809
+ { transient, background }
6810
+ );
6811
+ if (!background) tree.focus(leafId);
6738
6812
  this.renderer.render();
6739
6813
  this._persist();
6740
6814
  this._notifyChange("tab-open");
@@ -6746,15 +6820,20 @@ var WindowManager = class _WindowManager {
6746
6820
  * click from outside the tile system (e.g. the bottom-panel
6747
6821
  * "Add row" button, which passes no ctx) reliably lands as a sibling
6748
6822
  * tab in the main tile rather than swapping its content. */
6749
- openInTabInPrimary(kind, props = {}, transient = false) {
6823
+ openInTabInPrimary(kind, props = {}, transient = false, background = false) {
6750
6824
  const tree = this._tree();
6751
6825
  const leafId = tree.primaryLeafId();
6752
6826
  if (!leafId) {
6753
6827
  this._navigateWindow(kind, props);
6754
6828
  return;
6755
6829
  }
6756
- tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
6757
- tree.focus(leafId);
6830
+ tree.appendLeafTab(
6831
+ leafId,
6832
+ { kind, props },
6833
+ _tabTitle(kind, props),
6834
+ { transient, background }
6835
+ );
6836
+ if (!background) tree.focus(leafId);
6758
6837
  this.renderer.render();
6759
6838
  this._persist();
6760
6839
  this._notifyChange("tab-open");
@@ -6825,6 +6904,9 @@ function _swapSiblings(tree, aId, bId) {
6825
6904
  parent.sizes[j] = size;
6826
6905
  return true;
6827
6906
  }
6907
+ function _isEntityTab(tab) {
6908
+ return !!tab && tab.props != null && tab.props.id != null && tab.props.id !== "";
6909
+ }
6828
6910
 
6829
6911
  // src/tiling/tile_tab_menu.js
6830
6912
  var PAGE_SIZE = 10;
@@ -7215,6 +7297,112 @@ function _esc7(s) {
7215
7297
  })[c]);
7216
7298
  }
7217
7299
 
7300
+ // src/tiling/zoom.js
7301
+ var ZOOM_MIN = 50;
7302
+ var ZOOM_MAX = 200;
7303
+ var ZOOM_STEP = 5;
7304
+ var ZOOM_NUDGE = 10;
7305
+ var ZOOM_DEFAULT = 100;
7306
+ var ZOOM_STATE_KEY = "zoom";
7307
+ function clampZoom(value) {
7308
+ if (value == null || value === "") return ZOOM_DEFAULT;
7309
+ const n = Number(value);
7310
+ if (!Number.isFinite(n)) return ZOOM_DEFAULT;
7311
+ const stepped = Math.round(n / ZOOM_STEP) * ZOOM_STEP;
7312
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, stepped));
7313
+ }
7314
+ function applyZoom(root, percent) {
7315
+ if (!root?.style) return;
7316
+ root.style.setProperty("--twm-zoom", String(percent / 100));
7317
+ root.classList.toggle("twm-zoomed", percent !== ZOOM_DEFAULT);
7318
+ }
7319
+ function mountZoomControl(hostEl, { root, host = null, stateKey = ZOOM_STATE_KEY, onChange } = {}) {
7320
+ if (!hostEl || !root) return null;
7321
+ const doc = hostEl.ownerDocument;
7322
+ let current = ZOOM_DEFAULT;
7323
+ let disposed = false;
7324
+ const el = doc.createElement("div");
7325
+ el.className = "twm-zoom";
7326
+ const stepButton = (label, title, delta) => {
7327
+ const b = doc.createElement("button");
7328
+ b.type = "button";
7329
+ b.className = "twm-zoom__step";
7330
+ b.textContent = label;
7331
+ b.title = title;
7332
+ b.setAttribute("aria-label", title);
7333
+ b.addEventListener("click", () => commit(clampZoom(current + delta)));
7334
+ return b;
7335
+ };
7336
+ const slider = doc.createElement("input");
7337
+ slider.type = "range";
7338
+ slider.className = "twm-zoom__slider";
7339
+ slider.min = String(ZOOM_MIN);
7340
+ slider.max = String(ZOOM_MAX);
7341
+ slider.step = String(ZOOM_STEP);
7342
+ slider.value = String(ZOOM_DEFAULT);
7343
+ slider.title = `Zoom the workspace, ${ZOOM_MIN}\u2013${ZOOM_MAX}% \u2014 double-click to reset.`;
7344
+ slider.setAttribute("aria-label", "Zoom the workspace");
7345
+ slider.addEventListener("input", () => commit(clampZoom(slider.value)));
7346
+ slider.addEventListener("dblclick", () => commit(ZOOM_DEFAULT));
7347
+ const readout = doc.createElement("button");
7348
+ readout.type = "button";
7349
+ readout.className = "twm-zoom__value";
7350
+ readout.title = `Back to ${ZOOM_DEFAULT}%`;
7351
+ readout.addEventListener("click", () => commit(ZOOM_DEFAULT));
7352
+ el.append(
7353
+ stepButton("\u2212", `Zoom out ${ZOOM_NUDGE}%`, -ZOOM_NUDGE),
7354
+ slider,
7355
+ stepButton("+", `Zoom in ${ZOOM_NUDGE}%`, ZOOM_NUDGE),
7356
+ readout
7357
+ );
7358
+ hostEl.appendChild(el);
7359
+ const paint = (percent) => {
7360
+ current = percent;
7361
+ slider.value = String(percent);
7362
+ readout.textContent = `${percent}%`;
7363
+ applyZoom(root, percent);
7364
+ };
7365
+ let saveTimer = 0;
7366
+ const persist = (percent) => {
7367
+ const state = host?.state;
7368
+ if (!state) return;
7369
+ clearTimeout(saveTimer);
7370
+ saveTimer = setTimeout(() => {
7371
+ Promise.resolve().then(() => state.write(stateKey, percent)).catch((err) => console.warn("[zoom] save failed", err));
7372
+ }, 400);
7373
+ };
7374
+ const commit = (percent) => {
7375
+ if (disposed || percent === current) return;
7376
+ paint(percent);
7377
+ persist(percent);
7378
+ try {
7379
+ onChange?.(percent);
7380
+ } catch (err) {
7381
+ console.warn("[zoom] onChange threw", err);
7382
+ }
7383
+ };
7384
+ paint(ZOOM_DEFAULT);
7385
+ const ready = Promise.resolve().then(() => host?.state?.read?.(stateKey)).then((saved) => {
7386
+ if (!disposed && saved != null) paint(clampZoom(saved));
7387
+ return current;
7388
+ }).catch((err) => {
7389
+ console.warn("[zoom] load failed", err);
7390
+ return current;
7391
+ });
7392
+ return {
7393
+ el,
7394
+ get: () => current,
7395
+ set: (percent) => commit(clampZoom(percent)),
7396
+ ready,
7397
+ dispose: () => {
7398
+ disposed = true;
7399
+ clearTimeout(saveTimer);
7400
+ el.remove();
7401
+ applyZoom(root, ZOOM_DEFAULT);
7402
+ }
7403
+ };
7404
+ }
7405
+
7218
7406
  // src/tiling/shell.js
7219
7407
  async function createShell({
7220
7408
  root,
@@ -7232,6 +7420,7 @@ async function createShell({
7232
7420
  panels = null,
7233
7421
  snapPromotion = false,
7234
7422
  promoteInPlace = false,
7423
+ backToOpenList = false,
7235
7424
  tabLayout = null,
7236
7425
  chrome = {},
7237
7426
  // An embedder that moved its sections out of the top bar — into an icon
@@ -7271,6 +7460,7 @@ async function createShell({
7271
7460
  panelDefaults: panels,
7272
7461
  snapPromotion,
7273
7462
  promoteInPlace,
7463
+ backToOpenList,
7274
7464
  tabLayout,
7275
7465
  // `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
7276
7466
  // spreads it into every content factory, which is how the breadcrumb
@@ -7302,6 +7492,8 @@ async function createShell({
7302
7492
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
7303
7493
  topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
7304
7494
  desktopsEl = mountDesktopBar(chrome.desktops, wm);
7495
+ const zoom = mountZoomControl(chrome.zoom, { root, host });
7496
+ if (zoom) await zoom.ready;
7305
7497
  toggles = bindPanelToggles(chrome.panelToggles, wm);
7306
7498
  eventBus?.on?.("wm:changed", syncChrome);
7307
7499
  await wm.load();
@@ -7318,7 +7510,10 @@ async function createShell({
7318
7510
  paletteButtonEl: paletteBtn,
7319
7511
  topNavEl,
7320
7512
  desktopsEl,
7321
- panelToggles: toggles
7513
+ panelToggles: toggles,
7514
+ // `get`/`set` so an embedder can drive the zoom from its own settings
7515
+ // pane, or read it, without reaching into the control's DOM.
7516
+ zoom
7322
7517
  }),
7323
7518
  // A shell that can be built can be built TWICE — an embedder that
7324
7519
  // rebuilds on a context change (a different project, a different
@@ -7343,6 +7538,10 @@ async function createShell({
7343
7538
  } catch (err) {
7344
7539
  log.warn?.("renderer teardown", err);
7345
7540
  }
7541
+ try {
7542
+ zoom?.dispose();
7543
+ } catch {
7544
+ }
7346
7545
  }
7347
7546
  });
7348
7547
  }
@@ -7640,10 +7839,18 @@ export {
7640
7839
  TileRenderer,
7641
7840
  TileTree,
7642
7841
  WindowManager,
7842
+ ZOOM_DEFAULT,
7843
+ ZOOM_MAX,
7844
+ ZOOM_MIN,
7845
+ ZOOM_NUDGE,
7846
+ ZOOM_STATE_KEY,
7847
+ ZOOM_STEP,
7643
7848
  actionsCellRenderer,
7849
+ applyZoom,
7644
7850
  attachLandingKeyboardNav,
7645
7851
  attachLandingShell,
7646
7852
  attachLandingTableBehavior,
7853
+ clampZoom,
7647
7854
  createCommandPalette,
7648
7855
  createContentRegistry,
7649
7856
  createEntityCatalog,
@@ -7661,6 +7868,7 @@ export {
7661
7868
  mountLandingShell,
7662
7869
  mountNavPanel,
7663
7870
  mountTileBreadcrumb,
7871
+ mountZoomControl,
7664
7872
  openTileTabMenu,
7665
7873
  openTileTabSwitcher,
7666
7874
  registerPanelKeys,