estreui 1.4.0 → 1.5.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.
@@ -49,6 +49,12 @@ const estreUi = {
49
49
  panelCurrentOnTop: null,
50
50
  headerCurrentOnTop: null,
51
51
 
52
+ // External back handler stack — host-mounted external embeds register navigation
53
+ // steps here so native back input flows through them before EstreUI's section
54
+ // stack. Roadmap #011 / push-pop API.
55
+ externalBackStack: [],
56
+ nextBackHandlerToken: 1,
57
+
52
58
  //static getter
53
59
  get currentTopComponent() {
54
60
  return this.blindedCurrentOnTop ?? (this.isOpenMainMenu ? this.menuCurrentOnTop : null) ?? this.mainCurrentOnTop;
@@ -109,6 +115,7 @@ const estreUi = {
109
115
  $grabArea: null,
110
116
 
111
117
  $overwatchPanel: null,
118
+ $topLayer: null,
112
119
  get $panelSections() { return this.$panelBlock?.find(c.c + se + uis.blockItem) ?? $(); },
113
120
  $panelHeader: null,
114
121
  $panelHost: null,
@@ -197,6 +204,43 @@ const estreUi = {
197
204
  },
198
205
  get isDarkMode() { return document.body.dataset.darkMode == t1; },
199
206
 
207
+ // Cover bar — desktop-class viewport with a pointing device that can hover.
208
+ // Static environment check; matches the media query that gates the cover
209
+ // bar UI surface in CSS. See roadmap entry (forthcoming) for the wider
210
+ // host-integration design.
211
+ get isWideHoverViewport() {
212
+ return matchMedia("(min-width: 1025px) and (min-height: 769px) and (hover: hover) and (pointer: fine)").matches;
213
+ },
214
+
215
+ // Cover bar — whether the rootbar is currently in its extended layout, i.e.,
216
+ // the non-rootbar nav siblings under fixedBottom (`customFixedSections`,
217
+ // `instantSections`) take flex-grow:1 per estreUiCore.css's `@media all and
218
+ // (min-height: 700px) and (min-width: 740px)` block, AND the cover-bar
219
+ // handle is initialized. Combines static media-query state with dynamic
220
+ // bootstrap state.
221
+ get rootBarExtended() {
222
+ if (this.coverBarHandle == null) return false;
223
+ const fb = this.$fixedBottom?.[0] ?? this.$fixedBottom;
224
+ const nav = fb?.querySelector?.("nav:not(#rootbar)");
225
+ if (nav == null) return false;
226
+ return getComputedStyle(nav).flexGrow === "1";
227
+ },
228
+
229
+ // Cover bar — composite readiness: the environment supports the bar (wide
230
+ // viewport with hover) AND the rootbar is in its extended layout with a
231
+ // handle already initialized. Cover entries should only be pushed when
232
+ // this returns true; the handle itself is idempotent against pushes that
233
+ // arrive while still false.
234
+ get isInstantBarReady() {
235
+ return this.isWideHoverViewport && this.rootBarExtended;
236
+ },
237
+
238
+ // Placeholder for the cover-bar handle introduced in Phase 1C. Held at null
239
+ // so rootBarExtended can short-circuit before initialization. Naming follows
240
+ // EstreUI's stock handle convention (EstreHandle, EstreSwipeHandler, etc.)
241
+ // even though the cover bar isn't DOM-attached the same way.
242
+ coverBarHandle: null,
243
+
200
244
 
201
245
 
202
246
  //links (object redirection)
@@ -225,6 +269,8 @@ const estreUi = {
225
269
 
226
270
  this.$fixedBottom = $("#fixedBottom");
227
271
 
272
+ this.$topLayer = $("#topLayer");
273
+
228
274
  this.$handlePrototypes = $("section#handlePrototypes");
229
275
 
230
276
 
@@ -239,6 +285,7 @@ const estreUi = {
239
285
  this.$tabsbar = this.$fixedBottom.find(".tabsbar");
240
286
  this.$rootbar = this.$fixedBottom.find("nav#rootbar");
241
287
  this.initRootbar();
288
+ this.initCoverBar();
242
289
  }
243
290
 
244
291
  const onLoadedFixedTop = subTerm => {
@@ -891,83 +938,17 @@ const estreUi = {
891
938
  }
892
939
 
893
940
  this.$rootTabs.filter(ax(eds.tabId)).click(this.rootTabOnClick);
894
-
895
-
896
- // * Currently not using
897
- // fetch("./structure/rootmenu" + estreStruct.structureSuffix)
898
- // .then((response) => {
899
- // if (response.ok) return response.json();
900
- // throw Error("[" + response.status + "]" + response.url);
901
- // })
902
- // .then((data) => estreUi.renderRootBar(data))
903
- // .catch((error) => console.log("fetch error: " + error));
904
941
  },
905
942
 
906
- // === Currently not using
907
- renderRootBar(esd) {
908
- this.$rootTabs.empty();
909
- this.$mainArea.empty();
910
- var topId = null;
911
- for (var item of esd.menu) {
912
- this.$rootTabs.append(this.buildRootTabItem(item));
913
- this.$mainArea.append(this.buildMainSection(item));
914
- if (item.type == "static" && item.home) topId = item.id;
915
- }
916
- this.$rootTabs = this.$rootbar.find(c.c + btn);
917
-
918
- if (topId != null) {
919
- this.$rootTabs.filter(aiv(eds.tabId, topId)).attr(eds.active, t1);
920
- }
921
-
922
- this.$rootTabs.filter(ax(eds.tabId)).click(this.rootTabOnClick);
943
+ // Cover bar instantiates the handle bound to the loaded fixedBottom
944
+ // markup. Idempotent: subsequent calls noop, so reload paths are safe.
945
+ // Phase 1C-2/1C-3 add lifecycle hooks and entry rendering on top of this.
946
+ initCoverBar() {
947
+ if (this.coverBarHandle != null) return;
948
+ if (this.$fixedBottom == null || this.$fixedBottom.length === 0) return;
949
+ this.coverBarHandle = new EstreCoverBarHandle(this.$fixedBottom, this.$topLayer);
923
950
  },
924
951
 
925
- buildRootTabItem(esm) {
926
- const element = doc.ce(btn);
927
- element.setAttribute(m.cls, "tp_tiled_btn");
928
- element.setAttribute("title", esm.desc);
929
- element.setAttribute(eds.tabId, esm.id);
930
- element.innerHTML = esm.title;
931
- return element;
932
- },
933
-
934
- buildMainSection(esm) {
935
- const element = doc.ce(se);
936
- element.setAttribute(m.cls, "vfv_scroll");
937
- element.setAttribute("id", esm.id);
938
- this.fetchContent(esm, element);
939
- return element;
940
- },
941
-
942
- fetchContent(esm, target) {
943
- return fetch("." + esm.direct + estreStruct.structureSuffix)
944
- .then((response) => {
945
- if (response.ok) return response.json();
946
- throw Error("[" + response.status + "]" + response.url);
947
- })
948
- .then((data) => {
949
- const parts = this.renderContentArea(data);
950
- for (var part of parts) target.append(part);
951
- })
952
- .catch((error) => {
953
- if (window.isLogging) console.error("fetch error: " + error);
954
- });
955
- },
956
-
957
- renderContentArea(ecm) {
958
- const set = [];
959
- const article = doc.ce(ar);
960
- if (ecm.content.display == "constraint") article.setAttribute(m.cls, "constraint");
961
- set.push(article);
962
- for (var handle of handles) {
963
- const handler = doc.ce(div);
964
- handler.setAttribute(m.cls, "handle_set " + handle.attach);
965
- set.push(handler);
966
- }
967
- return set;
968
- },
969
- // ===========================
970
-
971
952
  showExactAppbar(component, container, article) {
972
953
  const appbar = this.appbar;
973
954
  if (appbar == null) return;
@@ -1845,9 +1826,168 @@ const estreUi = {
1845
1826
  },
1846
1827
 
1847
1828
  async onBack() {
1848
- return await this.onBackOverlay() || onBackWhile() ||
1849
- this.isOpenMainMenu ? await this.onBackMenu() || await this.closeMainMenu() : false ||
1850
- await this.onBackBlinded() || await this.onBackMain();
1829
+ // External handler stack first (LIFO). Each entry can absorb the back
1830
+ // input by returning truthy; falsy lets the next entry (and finally the
1831
+ // EstreUI section stack below) try. Errors are isolated and logged.
1832
+ for (let i = this.externalBackStack.length - 1; i >= 0; i--) {
1833
+ try {
1834
+ if (await this.externalBackStack[i].handler()) return true;
1835
+ } catch (e) {
1836
+ if (window.isLogging) console.warn("[estreUi] external back handler error", e);
1837
+ }
1838
+ }
1839
+ if (await this.onBackOverlay()) return true;
1840
+ if (onBackWhile()) return true;
1841
+ if (this.isOpenMainMenu) {
1842
+ return await this.onBackMenu() || await this.closeMainMenu();
1843
+ }
1844
+ return await this.onBackBlinded() || await this.onBackMain();
1845
+ },
1846
+
1847
+ /**
1848
+ * Register a back handler from a host-mounted external embed.
1849
+ *
1850
+ * Handlers are consumed LIFO — the most recent push runs first on the next
1851
+ * `back()` / popstate. Returning `true` (or a Promise resolving truthy)
1852
+ * stops the chain and absorbs the back input; returning a falsy value lets
1853
+ * the previous entry (or the EstreUI section stack) try.
1854
+ *
1855
+ * Re-entry is fine: the same `handler` may be pushed multiple times; each
1856
+ * push gets a separate token and counts as a separate stack entry.
1857
+ *
1858
+ * @param {() => boolean | Promise<boolean>} handler
1859
+ * @returns {number | null} Token for `popBackHandler`, or `null` if `handler` is invalid.
1860
+ */
1861
+ pushBackHandler(handler) {
1862
+ if (typeof handler !== "function") {
1863
+ if (window.isLogging) console.warn("[estreUi] pushBackHandler — handler must be a function");
1864
+ return null;
1865
+ }
1866
+ const token = this.nextBackHandlerToken++;
1867
+ this.externalBackStack.push({ token, handler });
1868
+ return token;
1869
+ },
1870
+
1871
+ /**
1872
+ * Remove a previously pushed handler. Out-of-order pop is allowed —
1873
+ * tokens identify entries independently of their stack position.
1874
+ *
1875
+ * @param {number} token
1876
+ * @returns {boolean} `true` if an entry was removed, `false` if the token was unknown.
1877
+ */
1878
+ popBackHandler(token) {
1879
+ const idx = this.externalBackStack.findIndex(it => it.token === token);
1880
+ if (idx < 0) {
1881
+ if (window.isLogging) console.warn("[estreUi] popBackHandler — token not found:", token);
1882
+ return false;
1883
+ }
1884
+ this.externalBackStack.splice(idx, 1);
1885
+ return true;
1886
+ },
1887
+
1888
+ /**
1889
+ * Drop every external back handler — fallback cleanup for embed teardown
1890
+ * paths that cannot match tokens individually. Embeds should prefer paired
1891
+ * push/pop and reach for this only as a safety net.
1892
+ */
1893
+ clearAllExternalBackHandlers() {
1894
+ this.externalBackStack.length = 0;
1895
+ },
1896
+
1897
+
1898
+ // ─── instantSections — external embed hook (cover bar, Phase 3) ───
1899
+ //
1900
+ // Light-DOM-mounted embeds (mango-class talk, future docked tools…) can
1901
+ // surface their own windows in the right-side cover bar (instantSections)
1902
+ // using these four wrappers. Internally they delegate to the singleton
1903
+ // EstreCoverBarHandle managed by initCoverBar(); each push returns a
1904
+ // monotone positive integer token. The user's intent is reported back
1905
+ // through the per-entry `onAction(action)` callback, where `action` is
1906
+ // one of "focus" / "minimize" / "restore" / "close". The embed owns the
1907
+ // actual window transition — the bar only routes intent.
1908
+
1909
+ /**
1910
+ * Register an external cover-bar entry. Returns a token; pass it back to
1911
+ * `updateInstantSectionEntry` / `removeInstantSectionEntry` /
1912
+ * `setInstantSectionActiveByToken`.
1913
+ *
1914
+ * With `closable: true` an ✕ button is rendered on the entry; clicking
1915
+ * it fires `onAction("close")`. The embed is responsible for the actual
1916
+ * close (so async confirm / server roundtrip can run first) and must
1917
+ * call `removeInstantSectionEntry` once the close completes.
1918
+ *
1919
+ * @param {object} data
1920
+ * @param {string} [data.title]
1921
+ * @param {string|null|undefined} [data.icon] — empty / undefined → sectionBound default, "none" / null → text-only, "<url>" → that URL
1922
+ * @param {string} [data.sectionBound] — "main" | "blind" | "overlay", drives the default icon
1923
+ * @param {(action: "focus"|"minimize"|"restore"|"close") => void} [data.onAction]
1924
+ * @param {boolean} [data.closable] — render an ✕ that fires onAction("close")
1925
+ * @param {number} [data.badge] — unread / notification count (0 or null → no badge, 1 → dot, 2-99 → numeric, >99 → "99+")
1926
+ * @returns {number|null} token, or `null` if the cover bar isn't initialised
1927
+ */
1928
+ pushInstantSectionEntry(data) {
1929
+ if (this.coverBarHandle == null) {
1930
+ if (window.isLogging) console.warn("[estreUi] pushInstantSectionEntry — coverBarHandle not initialised");
1931
+ return null;
1932
+ }
1933
+ return this.coverBarHandle.pushEntry({
1934
+ title: data?.title,
1935
+ icon: data?.icon,
1936
+ sectionBound: data?.sectionBound,
1937
+ onAction: data?.onAction,
1938
+ closable: data?.closable,
1939
+ badge: data?.badge,
1940
+ });
1941
+ },
1942
+
1943
+ /**
1944
+ * Patch an existing external entry. `partial` may carry any of `title`,
1945
+ * `icon`, `onAction`, `closable`. Returns false if the token is unknown
1946
+ * or the cover bar isn't initialised.
1947
+ *
1948
+ * @param {number} token
1949
+ * @param {object} partial
1950
+ * @returns {boolean}
1951
+ */
1952
+ updateInstantSectionEntry(token, partial) {
1953
+ return this.coverBarHandle?.updateEntry(token, partial) ?? false;
1954
+ },
1955
+
1956
+ /**
1957
+ * Remove an external entry. The bar detaches the DOM and forgets the
1958
+ * state; `activeToken` is cleared if the removed entry was active.
1959
+ *
1960
+ * @param {number} token
1961
+ * @returns {boolean}
1962
+ */
1963
+ removeInstantSectionEntry(token) {
1964
+ return this.coverBarHandle?.removeEntry(token) ?? false;
1965
+ },
1966
+
1967
+ /**
1968
+ * Mark an external entry active (visually highlighted on the bar).
1969
+ * Call this from the embed when its window gains focus through some
1970
+ * other path than a bar click — e.g., the user clicked inside the
1971
+ * embed itself.
1972
+ *
1973
+ * @param {number} token
1974
+ * @returns {boolean}
1975
+ */
1976
+ setInstantSectionActiveByToken(token) {
1977
+ return this.coverBarHandle?.setActiveByToken(token) ?? false;
1978
+ },
1979
+
1980
+ /**
1981
+ * Mark an external entry minimized / restored. Call this from the embed
1982
+ * when its window's visibility changes through a path other than a bar
1983
+ * click — e.g., the embed exposed its own minimize control.
1984
+ *
1985
+ * @param {number} token
1986
+ * @param {boolean} minimized
1987
+ * @returns {boolean}
1988
+ */
1989
+ setInstantSectionMinimizedByToken(token, minimized) {
1990
+ return this.coverBarHandle?.setMinimizedByToken(token, minimized) ?? false;
1851
1991
  },
1852
1992
 
1853
1993
  async onCloseContainer() {
@@ -2015,3 +2155,823 @@ const estreUi = {
2015
2155
  eoo: eoo
2016
2156
  }
2017
2157
 
2158
+ /**
2159
+ * Cover bar handle — owns the bottom-bar entry list that mirrors opt-in pages
2160
+ * (instantDoc / managedOverlay sections marked with `data-cover-mount`) and,
2161
+ * in Phase 3, external-embed windows registered via the public push API.
2162
+ *
2163
+ * Named with the `Handle` suffix to align with EstreUI's stock handle
2164
+ * convention (EstreHandle, EstreSwipeHandler, etc.) even though this one
2165
+ * isn't DOM-element-attached — it manages the bar surface as a single owner.
2166
+ *
2167
+ * Phase 1C-1 scope: scaffolding only — area refs cached, entry CRUD comes in
2168
+ * 1C-2 and entry rendering in 1C-3. The handle is instantiated by
2169
+ * estreUi.initCoverBar() during fixedBottom load (see onLoadedFixedBottom).
2170
+ */
2171
+ class EstreCoverBarHandle {
2172
+
2173
+ /**
2174
+ * Reusable 14x14 SVG glyphs for the context menu. Same stroke weight and
2175
+ * currentColor convention as the hide-all toggle / overflow sentinel, so
2176
+ * host code can grab the same icons when opening its own menus via
2177
+ * openContextMenu(). New icons should follow the same 14x14 viewBox +
2178
+ * stroke-only / currentColor pattern.
2179
+ *
2180
+ * center → 4-directional inward arrows pointing at the middle (cross
2181
+ * shape so users don't confuse it with the diagonal-X close)
2182
+ * minimize → single low bar (Windows-style _)
2183
+ * restore → outlined rect (window frame)
2184
+ * close → diagonal-X
2185
+ */
2186
+ static menuIcons = {
2187
+ center:
2188
+ '<svg viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
2189
+ + '<path d="M7 1.5v3.5M5.5 3.5L7 5l1.5-1.5"/>'
2190
+ + '<path d="M7 12.5V9M5.5 10.5L7 9l1.5 1.5"/>'
2191
+ + '<path d="M1.5 7h3.5M3.5 5.5L5 7l-1.5 1.5"/>'
2192
+ + '<path d="M12.5 7H9M10.5 5.5L9 7l1.5 1.5"/>'
2193
+ + '</svg>',
2194
+ minimize:
2195
+ '<svg viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false">'
2196
+ + '<path d="M3 10h8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>'
2197
+ + '</svg>',
2198
+ restore:
2199
+ '<svg viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false">'
2200
+ + '<rect x="3" y="3" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/>'
2201
+ + '</svg>',
2202
+ close:
2203
+ '<svg viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false">'
2204
+ + '<path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>'
2205
+ + '</svg>',
2206
+ };
2207
+
2208
+ #instantSections = null;
2209
+ #customFixedSections = null;
2210
+ #topLayer = null;
2211
+ #instantSentinel = null;
2212
+ #customFixedSentinel = null;
2213
+ #resizeObserver = null;
2214
+ #entries = [];
2215
+ #nextToken = 1;
2216
+ #activeToken = null;
2217
+ #openDropdown = null;
2218
+ #openContextMenu = null;
2219
+ #onDocumentPointerDown = null;
2220
+ #onDocumentKeydown = null;
2221
+
2222
+ constructor($fixedBottom, $topLayer) {
2223
+ // Accept either a jQuery wrapper or a native element; the cover bar is
2224
+ // intentionally jQuery-agnostic internally so it stays usable under the
2225
+ // estreU0EEOZ jQuery fallback (jsdom test environment) as well as the
2226
+ // real jQuery in browsers.
2227
+ const fb = $fixedBottom?.[0] ?? $fixedBottom;
2228
+ this.#instantSections = fb?.querySelector?.("nav#instantSections") ?? null;
2229
+ this.#customFixedSections = fb?.querySelector?.("nav#customFixedSections") ?? null;
2230
+ // Top-layer host for overflow dropdowns (Phase 2C). Optional — falls
2231
+ // back to null when the host hasn't mounted the slot. The bar still
2232
+ // renders entries; overflow dropdowns simply do not appear.
2233
+ const tl = $topLayer?.[0] ?? $topLayer;
2234
+ this.#topLayer = tl ?? null;
2235
+
2236
+ // Overflow sentinels — a ⌃-caret button at the leading edge of each
2237
+ // area. Always appended once; visibility flips via the `hidden` attr
2238
+ // as #recomputeOverflow measures area space against entry width.
2239
+ // instantSections is right-aligned (justify-content: flex-end) so
2240
+ // the sentinel ends up at the left edge of the visible cluster —
2241
+ // i.e., it points at the overflowed (clipped) side. The sentinel
2242
+ // is prepended so newly pushed entries (appended) always render to
2243
+ // its trailing side.
2244
+ if (this.#instantSections != null) {
2245
+ this.#instantSentinel = this.#createSentinel("instant");
2246
+ this.#instantSections.appendChild(this.#instantSentinel);
2247
+ }
2248
+ if (this.#customFixedSections != null) {
2249
+ this.#customFixedSentinel = this.#createSentinel("custom-fixed");
2250
+ this.#customFixedSections.appendChild(this.#customFixedSentinel);
2251
+ }
2252
+
2253
+ // ResizeObserver watches both areas so overflow recomputes when the
2254
+ // host viewport changes or the bar's siblings adjust. Falls back to a
2255
+ // no-op if the API isn't available (rare; jsdom recent versions ship
2256
+ // ResizeObserver). Push/remove/update paths call #recomputeOverflow
2257
+ // directly so the bar stays in sync even without observer events.
2258
+ if (typeof ResizeObserver !== "undefined") {
2259
+ this.#resizeObserver = new ResizeObserver(() => this.#recomputeOverflow());
2260
+ if (this.#instantSections != null) this.#resizeObserver.observe(this.#instantSections);
2261
+ if (this.#customFixedSections != null) this.#resizeObserver.observe(this.#customFixedSections);
2262
+ }
2263
+
2264
+ // Global listeners — close the overflow dropdown on outside click or
2265
+ // Escape. Capture phase so an embed cannot swallow the pointerdown
2266
+ // before we see it (the dropdown lives in #topLayer and the user's
2267
+ // intent in clicking anywhere else is unambiguously "dismiss").
2268
+ const self = this;
2269
+ this.#onDocumentPointerDown = (event) => {
2270
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
2271
+ // Dropdown close exception list: its own element, its sentinel, and
2272
+ // any currently-open context menu (a context menu opened from a
2273
+ // dropdown row mounts as a sibling in #topLayer, so the dropdown
2274
+ // shouldn't treat clicks on the menu as "outside").
2275
+ if (self.#openDropdown != null
2276
+ && !path.includes(self.#openDropdown.element)
2277
+ && !path.includes(self.#openDropdown.sentinel)
2278
+ && (self.#openContextMenu == null || !path.includes(self.#openContextMenu.element))) {
2279
+ self.#closeDropdown();
2280
+ }
2281
+ if (self.#openContextMenu != null
2282
+ && !path.includes(self.#openContextMenu.element)) {
2283
+ self.#closeContextMenu();
2284
+ }
2285
+ };
2286
+ this.#onDocumentKeydown = (event) => {
2287
+ if (event.key !== "Escape") return;
2288
+ if (self.#openContextMenu != null) self.#closeContextMenu();
2289
+ else if (self.#openDropdown != null) self.#closeDropdown();
2290
+ };
2291
+ document.addEventListener("pointerdown", this.#onDocumentPointerDown, true);
2292
+ document.addEventListener("keydown", this.#onDocumentKeydown);
2293
+ }
2294
+
2295
+ get instantSections() { return this.#instantSections; }
2296
+ get customFixedSections() { return this.#customFixedSections; }
2297
+ get topLayer() { return this.#topLayer; }
2298
+ /** @deprecated jQuery-flavoured getter kept for legacy callers; prefer `instantSections`. */
2299
+ get $instantSections() { return this.#instantSections == null ? null : $(this.#instantSections); }
2300
+ /** @deprecated jQuery-flavoured getter kept for legacy callers; prefer `customFixedSections`. */
2301
+ get $customFixedSections() { return this.#customFixedSections == null ? null : $(this.#customFixedSections); }
2302
+ get entries() { return this.#entries; }
2303
+ get activeToken() { return this.#activeToken; }
2304
+
2305
+ /**
2306
+ * Register a new cover-bar entry and render its DOM into `instantSections`.
2307
+ * @param {object} data
2308
+ * @param {EstrePageHandle} [data.pageHandle] — page bound to this entry (null for external embeds in Phase 3)
2309
+ * @param {string} [data.sectionBound] — main / blind / overlay etc., drives the default icon
2310
+ * @param {string} [data.title]
2311
+ * @param {string|null|undefined} [data.icon]
2312
+ * @returns {number} token
2313
+ */
2314
+ pushEntry(data) {
2315
+ const token = this.#nextToken++;
2316
+ const entry = {
2317
+ token,
2318
+ pageHandle: data.pageHandle ?? null,
2319
+ sectionBound: data.sectionBound ?? null,
2320
+ title: data.title ?? null,
2321
+ icon: data.icon,
2322
+ // External-embed wiring (Phase 3). pageHandle and onAction are
2323
+ // mutually exclusive at the click-routing level: when pageHandle
2324
+ // is set, clicks call show/hide on it; otherwise onAction fires
2325
+ // with one of "focus" / "minimize" / "restore" / "close".
2326
+ onAction: typeof data.onAction === "function" ? data.onAction : null,
2327
+ closable: data.closable === true,
2328
+ badge: typeof data.badge === "number" ? data.badge : null,
2329
+ minimized: false,
2330
+ element: null,
2331
+ };
2332
+ this.#entries.push(entry);
2333
+ this.#renderEntry(entry);
2334
+ this.#refreshEntryBadge(entry);
2335
+ this.#recomputeOverflow();
2336
+ return token;
2337
+ }
2338
+
2339
+ removeEntry(token) {
2340
+ const idx = this.#entries.findIndex(e => e.token === token);
2341
+ if (idx < 0) return false;
2342
+ const entry = this.#entries[idx];
2343
+ entry.element?.remove();
2344
+ this.#entries.splice(idx, 1);
2345
+ if (this.#activeToken === token) this.#activeToken = null;
2346
+ this.#recomputeOverflow();
2347
+ return true;
2348
+ }
2349
+
2350
+ setActiveByToken(token) {
2351
+ // Passing null / undefined clears the active state. Useful for
2352
+ // cumulative-state reconcile flows where the payload may legitimately
2353
+ // have no active entry; without this the previously-active entry
2354
+ // would stay highlighted indefinitely.
2355
+ if (token == null) {
2356
+ if (this.#activeToken == null) return true;
2357
+ const prev = this.#entries.find(e => e.token === this.#activeToken);
2358
+ prev?.element?.setAttribute("data-active", "");
2359
+ this.#activeToken = null;
2360
+ return true;
2361
+ }
2362
+ if (this.#entries.findIndex(e => e.token === token) < 0) return false;
2363
+ if (this.#activeToken != null && this.#activeToken !== token) {
2364
+ const prev = this.#entries.find(e => e.token === this.#activeToken);
2365
+ prev?.element?.setAttribute("data-active", "");
2366
+ }
2367
+ this.#activeToken = token;
2368
+ const entry = this.#entries.find(e => e.token === token);
2369
+ entry?.element?.setAttribute("data-active", "1");
2370
+ return true;
2371
+ }
2372
+
2373
+ setMinimizedByToken(token, minimized) {
2374
+ const entry = this.#entries.find(e => e.token === token);
2375
+ if (entry == null) return false;
2376
+ entry.minimized = !!minimized;
2377
+ entry.element?.setAttribute("data-minimized", entry.minimized ? "1" : "");
2378
+ return true;
2379
+ }
2380
+
2381
+ updateEntry(token, partial) {
2382
+ const entry = this.#entries.find(e => e.token === token);
2383
+ if (entry == null) return false;
2384
+ if ("title" in partial) {
2385
+ entry.title = partial.title;
2386
+ const label = entry.element?.querySelector(":scope > label");
2387
+ if (label != null) label.textContent = entry.title ?? "";
2388
+ // Mirror the title attr (native tooltip) so hover stays in sync
2389
+ // with the visible label.
2390
+ if (entry.element != null) {
2391
+ if (entry.title != null) entry.element.setAttribute("title", entry.title);
2392
+ else entry.element.removeAttribute("title");
2393
+ }
2394
+ }
2395
+ if ("icon" in partial) {
2396
+ entry.icon = partial.icon;
2397
+ this.#refreshEntryIcon(entry);
2398
+ }
2399
+ if ("onAction" in partial) {
2400
+ entry.onAction = typeof partial.onAction === "function" ? partial.onAction : null;
2401
+ }
2402
+ if ("closable" in partial) {
2403
+ entry.closable = partial.closable === true;
2404
+ this.#refreshEntryClose(entry);
2405
+ }
2406
+ if ("badge" in partial) {
2407
+ entry.badge = typeof partial.badge === "number" ? partial.badge : null;
2408
+ this.#refreshEntryBadge(entry);
2409
+ }
2410
+ this.#recomputeOverflow();
2411
+ return true;
2412
+ }
2413
+
2414
+ /** Lookup entry by token. Returns the live object — callers should treat it as read-only. */
2415
+ findEntry(token) {
2416
+ return this.#entries.find(e => e.token === token) ?? null;
2417
+ }
2418
+
2419
+ /**
2420
+ * Resolve the icon URL for an entry per the cover-icon fallback rules:
2421
+ * - icon === undefined → unset → sectionBound default
2422
+ * - icon === "" → explicit blank → sectionBound default
2423
+ * - icon === "none" → text-only (returns null)
2424
+ * - icon === null → text-only (returns null)
2425
+ * - icon === "<url>" → that URL
2426
+ */
2427
+ #resolveIconUrl(entry) {
2428
+ const icon = entry.icon;
2429
+ if (icon === "none" || icon === null) return null;
2430
+ if (icon === "" || icon === undefined) return this.#defaultIconForSection(entry.sectionBound);
2431
+ return icon;
2432
+ }
2433
+
2434
+ #defaultIconForSection(sectionBound) {
2435
+ if (sectionBound === "main") return "./vectors/cover-icon-default-static.svg";
2436
+ if (sectionBound === "blind") return "./vectors/cover-icon-default-instant.svg";
2437
+ if (sectionBound === "overlay") return "./vectors/cover-icon-default-overlay.svg";
2438
+ return null;
2439
+ }
2440
+
2441
+ #renderEntry(entry) {
2442
+ if (this.#instantSections == null) return;
2443
+ const btn = document.createElement("button");
2444
+ btn.type = "button";
2445
+ btn.className = "clean cover_entry";
2446
+ btn.setAttribute("data-cover-token", entry.token);
2447
+ if (entry.sectionBound != null) btn.setAttribute("data-section-bound", entry.sectionBound);
2448
+ // Full title surfaces as the native tooltip — the visible label is
2449
+ // ellipsis-clipped on narrow tiles, but hovering reveals the rest.
2450
+ if (entry.title != null) btn.setAttribute("title", entry.title);
2451
+
2452
+ const iconUrl = this.#resolveIconUrl(entry);
2453
+ if (iconUrl != null) {
2454
+ const span = document.createElement("span");
2455
+ span.className = "cover_icon";
2456
+ const img = document.createElement("img");
2457
+ img.alt = "";
2458
+ img.src = iconUrl;
2459
+ span.appendChild(img);
2460
+ btn.appendChild(span);
2461
+ }
2462
+
2463
+ const label = document.createElement("label");
2464
+ label.textContent = entry.title ?? "";
2465
+ btn.appendChild(label);
2466
+
2467
+ const self = this;
2468
+ btn.addEventListener("click", () => self.#onEntryClicked(entry.token));
2469
+ btn.addEventListener("contextmenu", (event) => self.#onEntryContextMenu(entry.token, event));
2470
+
2471
+ entry.element = btn;
2472
+ this.#instantSections.appendChild(btn);
2473
+ if (entry.closable) this.#refreshEntryClose(entry);
2474
+ }
2475
+
2476
+ /**
2477
+ * Add / remove the close ✕ on an entry to match `entry.closable`. Idempotent
2478
+ * so it can be called from #renderEntry (initial paint) and updateEntry
2479
+ * (state change). The ✕ click fires onAction("close") only — the embed is
2480
+ * responsible for the actual close (so an async confirm / server roundtrip
2481
+ * can run first) and must call removeInstantSectionEntry afterwards.
2482
+ */
2483
+ #refreshEntryClose(entry) {
2484
+ if (entry.element == null) return;
2485
+ const existing = entry.element.querySelector(":scope > .cover_entry_close");
2486
+ if (!entry.closable) {
2487
+ existing?.remove();
2488
+ return;
2489
+ }
2490
+ if (existing != null) return;
2491
+ const close = document.createElement("span");
2492
+ close.className = "cover_entry_close";
2493
+ close.setAttribute("role", "button");
2494
+ close.setAttribute("aria-label", "Close");
2495
+ close.textContent = "✕";
2496
+ close.addEventListener("click", (event) => {
2497
+ event.stopPropagation();
2498
+ if (typeof entry.onAction === "function") entry.onAction("close");
2499
+ });
2500
+ entry.element.appendChild(close);
2501
+ }
2502
+
2503
+ /**
2504
+ * Cover-bar entry click handler. Routes to one of two surfaces based on
2505
+ * whether the entry has a page handle or an external onAction callback.
2506
+ * The state-to-intent mapping is the same in both branches — it mirrors
2507
+ * how task-switcher-style docks behave:
2508
+ *
2509
+ * - active & visible → hide / "minimize"
2510
+ * - inactive → show + focus / "focus"
2511
+ * - active & minimized → show + focus / "restore"
2512
+ *
2513
+ * For internal page entries the handle's own show/hide is invoked. For
2514
+ * external embed entries (Phase 3) the registered onAction callback is
2515
+ * fired with the corresponding action token; the embed owns the actual
2516
+ * focus/minimize/restore transition. Entries with neither route configured
2517
+ * noop on click.
2518
+ */
2519
+ #onEntryClicked(token) {
2520
+ const entry = this.#entries.find(e => e.token === token);
2521
+ if (entry == null) return;
2522
+ if (entry.pageHandle != null) {
2523
+ if (this.#activeToken === token && !entry.minimized) {
2524
+ entry.pageHandle.hide();
2525
+ } else {
2526
+ entry.pageHandle.show(true, true);
2527
+ }
2528
+ return;
2529
+ }
2530
+ if (typeof entry.onAction === "function") {
2531
+ let action;
2532
+ if (this.#activeToken === token && !entry.minimized) action = "minimize";
2533
+ else if (this.#activeToken === token && entry.minimized) action = "restore";
2534
+ else action = "focus";
2535
+ entry.onAction(action);
2536
+ }
2537
+ }
2538
+
2539
+ #refreshEntryIcon(entry) {
2540
+ if (entry.element == null) return;
2541
+ entry.element.querySelector(":scope > .cover_icon")?.remove();
2542
+ const iconUrl = this.#resolveIconUrl(entry);
2543
+ if (iconUrl == null) return;
2544
+ const span = document.createElement("span");
2545
+ span.className = "cover_icon";
2546
+ const img = document.createElement("img");
2547
+ img.alt = "";
2548
+ img.src = iconUrl;
2549
+ span.appendChild(img);
2550
+ entry.element.insertBefore(span, entry.element.firstChild);
2551
+ }
2552
+
2553
+ /**
2554
+ * Apply the project-wide [data-badge] attribute convention (see
2555
+ * estreUi.css `article [data-badge]::after`) to an element. Display
2556
+ * rules — kept consistent with `setMangoTalkBadge` / `setPushNotificationBadge`
2557
+ * elsewhere in the host so themes can override `--badge-color` once:
2558
+ * - count null / 0 / negative → attribute removed
2559
+ * - count === 1 → data-badge="" → dot (CSS :empty variant)
2560
+ * - 2 ≤ count ≤ 99 → data-badge="<count>" → numeric pill
2561
+ * - count > 99 → data-badge="99+"
2562
+ */
2563
+ #setBadgeAttr(target, count) {
2564
+ if (count == null || count <= 0) {
2565
+ target.removeAttribute("data-badge");
2566
+ return;
2567
+ }
2568
+ target.setAttribute("data-badge",
2569
+ count === 1 ? "" :
2570
+ count > 99 ? "99+" :
2571
+ String(count));
2572
+ }
2573
+
2574
+ /**
2575
+ * Sync the per-entry unread badge with `entry.badge`. The badge is
2576
+ * surfaced as a [data-badge] attribute on the entry's .cover_icon —
2577
+ * styled via the cover-bar-scoped ::after rule that mirrors the
2578
+ * project-wide article [data-badge] convention. Text-only entries
2579
+ * (icon: "none" / no .cover_icon host) silently no-op.
2580
+ */
2581
+ #refreshEntryBadge(entry) {
2582
+ if (entry.element == null) return;
2583
+ const iconHost = entry.element.querySelector(":scope > .cover_icon");
2584
+ if (iconHost == null) return; // text-only entries can't host the badge
2585
+ this.#setBadgeAttr(iconHost, entry.badge);
2586
+ }
2587
+
2588
+ /**
2589
+ * Create the per-area overflow sentinel button. Hidden by default; the
2590
+ * recompute pass flips `hidden` based on whether entries fit. Clicking
2591
+ * toggles the area's overflow dropdown (Phase 2C).
2592
+ *
2593
+ * The caret is rendered as an inline SVG so it can be precisely sized and
2594
+ * positioned (top-aligned on a narrow button) regardless of the platform
2595
+ * font. The shape mirrors the rootbar's chevron affordances — a thin
2596
+ * upward chevron stroked in currentColor so hover / data-opened states
2597
+ * pick up the surrounding color transitions for free.
2598
+ */
2599
+ #createSentinel(areaKey) {
2600
+ const btn = document.createElement("button");
2601
+ btn.type = "button";
2602
+ btn.className = "clean cover_overflow_sentinel";
2603
+ btn.setAttribute("data-area", areaKey);
2604
+ btn.setAttribute("aria-label", "Show overflowed entries");
2605
+ btn.innerHTML =
2606
+ '<svg viewBox="0 0 12 8" aria-hidden="true" focusable="false">' +
2607
+ '<polyline points="2,6 6,2 10,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
2608
+ '</svg>';
2609
+ btn.hidden = true;
2610
+ const self = this;
2611
+ btn.addEventListener("click", (event) => {
2612
+ event.stopPropagation();
2613
+ self.#toggleDropdown(areaKey);
2614
+ });
2615
+ return btn;
2616
+ }
2617
+
2618
+ /**
2619
+ * Re-evaluate overflow for both areas. Cheap: short loop guided by
2620
+ * `area.scrollWidth > area.clientWidth`. Called from push / remove /
2621
+ * update / ResizeObserver. Hidden entries (data-overflowed="1") drop
2622
+ * out of layout via CSS, so subsequent measurements reflect the new
2623
+ * width budget.
2624
+ */
2625
+ #recomputeOverflow() {
2626
+ // Both areas hide from the trailing (DOM-end / most-recently-pushed)
2627
+ // side. The sentinel sits at the trailing edge of the visible cluster
2628
+ // (`order: 1`), so the entries that disappear are the ones closest to
2629
+ // it — most-recently-pushed first. Users opening a fresh window see it
2630
+ // get pushed into the dropdown as the bar fills up; older windows
2631
+ // stay visible on the leading side where they were first placed.
2632
+ this.#recomputeAreaOverflow(this.#instantSections, this.#instantSentinel, "trailing");
2633
+ this.#recomputeAreaOverflow(this.#customFixedSections, this.#customFixedSentinel, "trailing");
2634
+ if (this.#openDropdown != null) this.#refreshOpenDropdown();
2635
+ }
2636
+
2637
+ /**
2638
+ * Hide entries one at a time from the chosen end until the cluster fits.
2639
+ * - leading → hide from index 0 forward (oldest first, suited to
2640
+ * flex-end areas where the leading edge is clipped)
2641
+ * - trailing → hide from index N-1 backward (newest first, suited to
2642
+ * flex-start areas where the trailing edge is clipped)
2643
+ */
2644
+ #recomputeAreaOverflow(area, sentinel, hideFrom) {
2645
+ if (area == null || sentinel == null) return;
2646
+ const entries = this.#entriesForArea(area);
2647
+ for (const e of entries) e.element?.removeAttribute("data-overflowed");
2648
+ sentinel.hidden = true;
2649
+ if (entries.length === 0) return;
2650
+ if (area.scrollWidth <= area.clientWidth) return;
2651
+
2652
+ // Reveal the sentinel so its width counts toward the budget; then
2653
+ // hide entries until the remaining cluster fits alongside it.
2654
+ sentinel.hidden = false;
2655
+ const indices = hideFrom === "leading"
2656
+ ? entries.map((_, i) => i)
2657
+ : entries.map((_, i) => entries.length - 1 - i);
2658
+ for (const i of indices) {
2659
+ if (area.scrollWidth <= area.clientWidth) return;
2660
+ entries[i].element?.setAttribute("data-overflowed", "1");
2661
+ }
2662
+ }
2663
+
2664
+ /** Currently only `instantSections` hosts cover-bar entries. customFixedSections
2665
+ * is reserved for future user-pinned items (see PM/002 task ledger §Phase 4+). */
2666
+ #entriesForArea(area) {
2667
+ if (area === this.#instantSections) return this.#entries;
2668
+ return [];
2669
+ }
2670
+
2671
+ /**
2672
+ * Open / close / toggle the overflow dropdown for an area. Single open
2673
+ * dropdown at a time; opening one while another is open swaps them.
2674
+ * No-op when the top-layer host slot is missing.
2675
+ */
2676
+ #toggleDropdown(areaKey) {
2677
+ if (this.#openDropdown != null && this.#openDropdown.areaKey === areaKey) {
2678
+ this.#closeDropdown();
2679
+ return;
2680
+ }
2681
+ this.#openDropdown != null && this.#closeDropdown();
2682
+ this.#openDropdown = this.#openDropdownFor(areaKey);
2683
+ }
2684
+
2685
+ #openDropdownFor(areaKey) {
2686
+ if (this.#topLayer == null) return null;
2687
+ const sentinel = areaKey === "instant" ? this.#instantSentinel : this.#customFixedSentinel;
2688
+ const area = areaKey === "instant" ? this.#instantSections : this.#customFixedSections;
2689
+ if (sentinel == null || area == null) return null;
2690
+
2691
+ const dropdown = document.createElement("div");
2692
+ dropdown.className = "cover_overflow_dropdown";
2693
+ dropdown.setAttribute("data-area", areaKey);
2694
+ this.#topLayer.appendChild(dropdown);
2695
+ sentinel.setAttribute("data-opened", "1");
2696
+
2697
+ const state = { areaKey, sentinel, area, element: dropdown };
2698
+ // Render and position after attaching so size can be measured.
2699
+ this.#renderDropdownRows(state);
2700
+ this.#positionDropdown(state);
2701
+ return state;
2702
+ }
2703
+
2704
+ #closeDropdown() {
2705
+ if (this.#openDropdown == null) return;
2706
+ this.#openDropdown.element.remove();
2707
+ this.#openDropdown.sentinel?.removeAttribute("data-opened");
2708
+ this.#openDropdown = null;
2709
+ }
2710
+
2711
+ /** Re-render rows + re-position the open dropdown. Called when the set
2712
+ * of overflowed entries changes (e.g. resize) while it's open. */
2713
+ #refreshOpenDropdown() {
2714
+ if (this.#openDropdown == null) return;
2715
+ // If the sentinel went away (everything fits again), drop the dropdown.
2716
+ if (this.#openDropdown.sentinel?.hidden) {
2717
+ this.#closeDropdown();
2718
+ return;
2719
+ }
2720
+ this.#renderDropdownRows(this.#openDropdown);
2721
+ this.#positionDropdown(this.#openDropdown);
2722
+ }
2723
+
2724
+ #renderDropdownRows(state) {
2725
+ const { element, area } = state;
2726
+ element.replaceChildren();
2727
+ // Newest hidden entries surface first — visually they sat closest to
2728
+ // the sentinel before being pushed off, so the user reads them at
2729
+ // the top of the dropdown. We hide from the trailing edge (most-
2730
+ // recently-pushed first), so reversing the natural DOM order lines
2731
+ // the dropdown rows up "newest → older" top to bottom.
2732
+ const entries = this.#entriesForArea(area)
2733
+ .filter(e => e.element?.getAttribute("data-overflowed") === "1")
2734
+ .reverse();
2735
+ for (const entry of entries) {
2736
+ const row = document.createElement("button");
2737
+ row.type = "button";
2738
+ row.className = "clean cover_entry";
2739
+ row.setAttribute("data-cover-token", entry.token);
2740
+ if (entry.sectionBound != null) row.setAttribute("data-section-bound", entry.sectionBound);
2741
+ if (this.#activeToken === entry.token) row.setAttribute("data-active", "1");
2742
+ if (entry.minimized) row.setAttribute("data-minimized", "1");
2743
+ if (entry.title != null) row.setAttribute("title", entry.title);
2744
+
2745
+ const iconUrl = this.#resolveIconUrl(entry);
2746
+ if (iconUrl != null) {
2747
+ const span = document.createElement("span");
2748
+ span.className = "cover_icon";
2749
+ const img = document.createElement("img");
2750
+ img.alt = "";
2751
+ img.src = iconUrl;
2752
+ span.appendChild(img);
2753
+ this.#setBadgeAttr(span, entry.badge);
2754
+ row.appendChild(span);
2755
+ }
2756
+ const label = document.createElement("label");
2757
+ label.textContent = entry.title ?? "";
2758
+ row.appendChild(label);
2759
+
2760
+ const self = this;
2761
+ row.addEventListener("click", (event) => {
2762
+ event.stopPropagation();
2763
+ self.#closeDropdown();
2764
+ self.#onEntryClicked(entry.token);
2765
+ });
2766
+ // Right-click inside the overflow dropdown opens the entry's
2767
+ // context menu on top of it — the dropdown stays open so the user
2768
+ // can pick another row after dismissing the menu. The document-
2769
+ // level pointerdown handler also exempts the open context menu
2770
+ // from closing the dropdown (see constructor).
2771
+ row.addEventListener("contextmenu", (event) => {
2772
+ self.#onEntryContextMenu(entry.token, event);
2773
+ });
2774
+ if (entry.closable) {
2775
+ const close = document.createElement("span");
2776
+ close.className = "cover_entry_close";
2777
+ close.setAttribute("role", "button");
2778
+ close.setAttribute("aria-label", "Close");
2779
+ close.textContent = "✕";
2780
+ close.addEventListener("click", (event) => {
2781
+ event.stopPropagation();
2782
+ if (typeof entry.onAction === "function") entry.onAction("close");
2783
+ });
2784
+ row.appendChild(close);
2785
+ }
2786
+ element.appendChild(row);
2787
+ }
2788
+ }
2789
+
2790
+ #positionDropdown(state) {
2791
+ const { element, sentinel, areaKey } = state;
2792
+ const rect = sentinel.getBoundingClientRect();
2793
+ // Anchor above the sentinel — fixedBottom sits at the screen base, so
2794
+ // the dropdown opens upward. Use `bottom` rather than `top` so the
2795
+ // dropdown grows up from the anchor as more rows are added.
2796
+ const gap = 6;
2797
+ element.style.bottom = `${Math.max(0, window.innerHeight - rect.top + gap)}px`;
2798
+ element.style.top = "auto";
2799
+ if (areaKey === "instant") {
2800
+ // Right-align to the area's right edge so the dropdown stays
2801
+ // within the host margin even when the sentinel itself sits
2802
+ // anywhere along the bar's leading edge.
2803
+ const areaRect = state.area.getBoundingClientRect();
2804
+ element.style.right = `${Math.max(0, window.innerWidth - areaRect.right)}px`;
2805
+ element.style.left = "auto";
2806
+ } else {
2807
+ const areaRect = state.area.getBoundingClientRect();
2808
+ element.style.left = `${Math.max(0, areaRect.left)}px`;
2809
+ element.style.right = "auto";
2810
+ }
2811
+ }
2812
+
2813
+ /**
2814
+ * Right-click on a cover-bar entry opens a small context menu in #topLayer:
2815
+ *
2816
+ * ── title ──────────────────
2817
+ * 화면 가운데로 이동 (placeholder until the embed exposes the API)
2818
+ * 최소화 / 복원 (label flips on entry.minimized)
2819
+ * 닫기 (fires onAction("close"))
2820
+ *
2821
+ * Internal page-handle entries don't get the menu — they have their own
2822
+ * navigation surface already. External-embed entries (onAction !== null)
2823
+ * are the target audience. The menu is single-instance: opening one closes
2824
+ * any prior open instance, and outside pointerdown / Escape close it (see
2825
+ * the document-level listeners in the constructor).
2826
+ */
2827
+ #onEntryContextMenu(token, event) {
2828
+ const entry = this.#entries.find(e => e.token === token);
2829
+ if (entry == null || typeof entry.onAction !== "function") return;
2830
+ if (this.#topLayer == null) return;
2831
+ event.preventDefault();
2832
+ if (this.#openContextMenu != null) this.#closeContextMenu();
2833
+ this.#openContextMenu = this.#openContextMenuFor(entry, event.clientX, event.clientY);
2834
+ }
2835
+
2836
+ #openContextMenuFor(entry, x, y) {
2837
+ // SVG glyphs picked for visual parity with the hide-all toggle and the
2838
+ // overflow sentinel — same stroke weight (1.6 ~ 1.8) and 14x14 viewBox,
2839
+ // all in currentColor so hover / disabled inherit the menu's text tone.
2840
+ const SVG = EstreCoverBarHandle.menuIcons;
2841
+ const items = [
2842
+ { label: "화면 가운데로 이동", action: "center", svg: SVG.center },
2843
+ entry.minimized
2844
+ ? { label: "복원", action: "restore", svg: SVG.restore }
2845
+ : { label: "최소화", action: "minimize", svg: SVG.minimize },
2846
+ { label: "닫기", action: "close", svg: SVG.close },
2847
+ ];
2848
+ return this.openContextMenu({
2849
+ x, y,
2850
+ title: entry.title,
2851
+ items,
2852
+ onAction: (action) => this.#onContextMenuAction(entry.token, action),
2853
+ anchorData: { "data-cover-token": entry.token },
2854
+ });
2855
+ }
2856
+
2857
+ /**
2858
+ * Public context-menu surface — opens a menu in #topLayer at the cursor,
2859
+ * matching the chrome the cover-bar uses internally for entry right-click.
2860
+ * Host code (e.g. for a rootbar tab) calls this directly with its own
2861
+ * items / onAction, instead of routing through a cover_entry.
2862
+ *
2863
+ * Items are `{ label, action, svg?, disabled? }`. The menu is single-
2864
+ * instance — opening one closes any prior open menu. Outside pointerdown
2865
+ * and Escape close it; both branches are wired in the constructor.
2866
+ *
2867
+ * Returns the menu state object (or null if #topLayer is missing).
2868
+ */
2869
+ openContextMenu({ x, y, title, items, onAction, anchorData }) {
2870
+ if (this.#topLayer == null) return null;
2871
+ if (this.#openContextMenu != null) this.#closeContextMenu();
2872
+
2873
+ const menu = document.createElement("div");
2874
+ menu.className = "cover_entry_menu";
2875
+ if (anchorData != null) {
2876
+ for (const [k, v] of Object.entries(anchorData)) menu.setAttribute(k, v);
2877
+ }
2878
+
2879
+ if (title != null) {
2880
+ const titleEl = document.createElement("header");
2881
+ titleEl.className = "cover_menu_title";
2882
+ titleEl.textContent = title;
2883
+ menu.appendChild(titleEl);
2884
+ }
2885
+
2886
+ const self = this;
2887
+ for (const it of items ?? []) {
2888
+ const item = document.createElement("button");
2889
+ item.type = "button";
2890
+ item.className = "clean cover_menu_item";
2891
+ item.setAttribute("data-action", it.action);
2892
+ const iconSpan = document.createElement("span");
2893
+ iconSpan.className = "cover_menu_item_icon";
2894
+ if (it.svg != null) iconSpan.innerHTML = it.svg;
2895
+ item.appendChild(iconSpan);
2896
+ const labelSpan = document.createElement("span");
2897
+ labelSpan.className = "cover_menu_item_label";
2898
+ labelSpan.textContent = it.label;
2899
+ item.appendChild(labelSpan);
2900
+ if (it.disabled) item.disabled = true;
2901
+ item.addEventListener("click", (ev) => {
2902
+ ev.stopPropagation();
2903
+ self.#closeContextMenu();
2904
+ onAction?.(it.action);
2905
+ });
2906
+ menu.appendChild(item);
2907
+ }
2908
+
2909
+ const state = { element: menu };
2910
+ this.#topLayer.appendChild(menu);
2911
+ this.#positionContextMenu(state, x, y);
2912
+ this.#openContextMenu = state;
2913
+ return state;
2914
+ }
2915
+
2916
+ /**
2917
+ * Anchor the context menu at the cursor and pick a quadrant so it always
2918
+ * opens *toward* the viewport center — bottom-right click → menu pinned to
2919
+ * its bottom-right (= grows up-and-left), top-left click → menu pinned
2920
+ * top-left (= grows down-and-right), etc. The transform-origin custom
2921
+ * property is set inline so the scale-grow open animation pivots at the
2922
+ * click point.
2923
+ */
2924
+ #positionContextMenu(state, x, y) {
2925
+ const { element } = state;
2926
+ const w = window.innerWidth;
2927
+ const h = window.innerHeight;
2928
+ const isRight = x > w / 2;
2929
+ const isBottom = y > h / 2;
2930
+ element.style.left = isRight ? "auto" : `${x}px`;
2931
+ element.style.right = isRight ? `${Math.max(0, w - x)}px` : "auto";
2932
+ element.style.top = isBottom ? "auto" : `${y}px`;
2933
+ element.style.bottom = isBottom ? `${Math.max(0, h - y)}px` : "auto";
2934
+ // Origin corner = the cursor-anchored corner; CSS uses keywords (`top`
2935
+ // / `bottom` / `left` / `right`) for transform-origin so it stays
2936
+ // pixel-exact regardless of layout shifts during animation.
2937
+ const vert = isBottom ? "bottom" : "top";
2938
+ const horiz = isRight ? "right" : "left";
2939
+ element.style.setProperty("--menu-origin", `${vert} ${horiz}`);
2940
+ }
2941
+
2942
+ /**
2943
+ * Fade the menu out (0.2s ease) before detaching, so the close looks like
2944
+ * a deliberate dismiss rather than a pop. The element is removed on the
2945
+ * transitionend; null-ing the state first prevents a re-entrant close from
2946
+ * doubling up the fade.
2947
+ */
2948
+ #closeContextMenu() {
2949
+ if (this.#openContextMenu == null) return;
2950
+ const el = this.#openContextMenu.element;
2951
+ this.#openContextMenu = null;
2952
+ el.setAttribute("data-closing", "1");
2953
+ const remove = () => el.remove();
2954
+ el.addEventListener("transitionend", remove, { once: true });
2955
+ // Safety fallback — if the animation is interrupted (e.g. element no
2956
+ // longer in DOM), still detach after the expected duration.
2957
+ setTimeout(remove, 260);
2958
+ }
2959
+
2960
+ /** Route a context-menu click to the right action. The first three actions
2961
+ * delegate to the same onAction surface as bar-click intent (so embed
2962
+ * wirings only need one handler); "center" is a placeholder action that
2963
+ * the embed may wire later as a window-center transition. */
2964
+ #onContextMenuAction(token, action) {
2965
+ const entry = this.#entries.find(e => e.token === token);
2966
+ if (entry == null || typeof entry.onAction !== "function") return;
2967
+ entry.onAction(action);
2968
+ }
2969
+ }
2970
+
2971
+
2972
+ // Expose estreUi on window so ES-module-realm host integrations (external embeds
2973
+ // loaded as <script type="module">) can reach the public API. Same-realm classic
2974
+ // scripts already see the lexical const; this line only adds the cross-realm
2975
+ // surface. See review #011.
2976
+ window.estreUi = estreUi;
2977
+