@yuneta/gobj-ui 5.4.0 → 5.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.
package/index.js CHANGED
@@ -24,6 +24,11 @@ export { register_c_yui_uplot } from "./src/c_yui_uplot.js";
24
24
  export { register_c_yui_json_graph } from "./src/c_yui_json_graph.js";
25
25
  export { register_c_yui_json } from "./src/c_yui_json.js";
26
26
  export { register_c_yui_gobj_tree_js } from "./src/c_yui_gobj_tree_js.js";
27
+ export {
28
+ register_c_yui_service_view,
29
+ yui_mount_service_view,
30
+ expose_view_container,
31
+ } from "./src/c_yui_service_view.js";
27
32
 
28
33
  /*
29
34
  * Declarative shell + menu navigation (new in v7.4)
package/package.json CHANGED
@@ -1,47 +1,47 @@
1
1
  {
2
- "name": "@yuneta/gobj-ui",
3
- "version": "5.4.0",
4
- "type": "module",
5
- "main": "dist/gobj-ui.cjs.js",
6
- "module": "dist/gobj-ui.es.js",
7
- "exports": {
8
- ".": {
9
- "import": "./dist/gobj-ui.es.js",
10
- "require": "./dist/gobj-ui.cjs.js"
11
- },
12
- "./index.js": "./index.js",
13
- "./vite-plugin-yuneta-html.js": "./vite-plugin-yuneta-html.js",
14
- "./src/*": "./src/*"
2
+ "name": "@yuneta/gobj-ui",
3
+ "version": "5.5.0",
4
+ "type": "module",
5
+ "main": "dist/gobj-ui.cjs.js",
6
+ "module": "dist/gobj-ui.es.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/gobj-ui.es.js",
10
+ "require": "./dist/gobj-ui.cjs.js"
15
11
  },
16
- "files": [
17
- "dist/gobj-ui.es.js",
18
- "dist/gobj-ui.cjs.js",
19
- "index.js",
20
- "src/",
21
- "vite-plugin-yuneta-html.js"
22
- ],
23
- "description": "Yuneta UI Library — the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed by wattyzer and the yunetas JS yunos (gui_agent, gui_treedb). The frozen legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) lives on the v1 branch, npm dist-tag 'legacy' (consumed by estadodelaire and hidraulia).",
24
- "scripts": {
25
- "build": "vite build",
26
- "test": "vitest run",
27
- "prepublishOnly": "npm run build"
28
- },
29
- "devDependencies": {
30
- "@rollup/plugin-terser": "^1.0.0",
31
- "@vitest/coverage-v8": "^4.1.10",
32
- "@yuneta/gobj-js": "^7.8.7",
33
- "vite": "^8.1.5",
34
- "vitest": "^4.1.10"
35
- },
36
- "peerDependencies": {
37
- "@antv/g6": "^5.1.0",
38
- "@yuneta/gobj-js": "^7.8.7",
39
- "bulma": "^1.0.4",
40
- "i18next": "^26.3.6",
41
- "maplibre-gl": "^6.0.0",
42
- "tabulator-tables": "^6.5.2",
43
- "tom-select": "^2.6.2",
44
- "uplot": "^1.6.32",
45
- "vanilla-jsoneditor": "^3.13.0"
46
- }
12
+ "./index.js": "./index.js",
13
+ "./vite-plugin-yuneta-html.js": "./vite-plugin-yuneta-html.js",
14
+ "./src/*": "./src/*"
15
+ },
16
+ "files": [
17
+ "dist/gobj-ui.es.js",
18
+ "dist/gobj-ui.cjs.js",
19
+ "index.js",
20
+ "src/",
21
+ "vite-plugin-yuneta-html.js"
22
+ ],
23
+ "description": "Yuneta UI Library — the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed by wattyzer and the yunetas JS yunos (gui_agent, gui_treedb). The frozen legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) lives on the v1 branch, npm dist-tag 'legacy' (consumed by estadodelaire and hidraulia).",
24
+ "scripts": {
25
+ "build": "vite build",
26
+ "test": "vitest run",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "devDependencies": {
30
+ "@rollup/plugin-terser": "^1.0.0",
31
+ "@vitest/coverage-v8": "^4.1.10",
32
+ "@yuneta/gobj-js": "^7.8.7",
33
+ "vite": "^8.1.5",
34
+ "vitest": "^4.1.10"
35
+ },
36
+ "peerDependencies": {
37
+ "@antv/g6": "^5.1.0",
38
+ "@yuneta/gobj-js": "^7.8.7",
39
+ "bulma": "^1.0.4",
40
+ "i18next": "^26.3.6",
41
+ "maplibre-gl": "^6.0.0",
42
+ "tabulator-tables": "^6.5.2",
43
+ "tom-select": "^2.6.2",
44
+ "uplot": "^1.6.32",
45
+ "vanilla-jsoneditor": "^3.13.0"
46
+ }
47
47
  }
package/src/c_yui_node.js CHANGED
@@ -137,6 +137,7 @@ import {
137
137
  yui_shell_zone,
138
138
  yui_shell_navigate,
139
139
  yui_shell_set_sub_routes,
140
+ yui_shell_translate,
140
141
  } from "./c_yui_shell.js";
141
142
 
142
143
  import "./c_yui_node.css";
@@ -582,6 +583,30 @@ function walk_path(node, path)
582
583
  return {node: g, missing: segs.slice(i)};
583
584
  }
584
585
 
586
+ /************************************************************
587
+ * Translate a subtree this node just built.
588
+ *
589
+ * A node renders its chrome WHEN YOU WALK INTO IT, long after
590
+ * the host's one-shot refresh_language over the shell tree, and
591
+ * its zone navs are appended into shell zones, outside this
592
+ * node's own $container. Neither pass reaches them, so without
593
+ * this every strip renders its raw i18n key: `treedb`,
594
+ * `central database`, `data` in lower-case English next to a
595
+ * translated menu — which reads as a MISSING key and sent more
596
+ * than one person looking for the wrong bug.
597
+ *
598
+ * This is library-built DOM, so it goes through the shell's
599
+ * registered translator. What a node MOUNTS is not ours: an app
600
+ * view gclass translates its own DOM.
601
+ ************************************************************/
602
+ function translate_own_dom(gobj, $el)
603
+ {
604
+ if(!$el) {
605
+ return;
606
+ }
607
+ yui_shell_translate(yui_shell_of(gobj), $el);
608
+ }
609
+
585
610
  /************************************************************
586
611
  * Destroy the C_YUI_NAV gobjs rendering this node's projection.
587
612
  ************************************************************/
@@ -669,6 +694,7 @@ function render_projection(gobj, mode, $where, active_route, active_id, zones_on
669
694
  let $nav = gobj_read_attr(nav, "$container");
670
695
  if($nav) {
671
696
  $target.appendChild($nav);
697
+ translate_own_dom(gobj, $nav);
672
698
  }
673
699
  priv.navs.push(nav);
674
700
  i++;
@@ -716,11 +742,14 @@ function project_into_zone(gobj, render, items, active_route, active_id)
716
742
  let $nav = gobj_read_attr(nav, "$container");
717
743
  if($nav) {
718
744
  $zone.appendChild($nav);
745
+ translate_own_dom(gobj, $nav);
719
746
  }
720
747
  priv.zone_navs[key] = nav;
721
748
  priv.zone_sig[key] = sig;
722
749
  } else if(priv.zone_sig[key] !== sig) {
723
750
  gobj_send_event(nav, "EV_SET_ITEMS", {items: items}, gobj);
751
+ /* EV_SET_ITEMS rebuilt the nav's DOM: translate it again. */
752
+ translate_own_dom(gobj, gobj_read_attr(nav, "$container"));
724
753
  priv.zone_sig[key] = sig;
725
754
  }
726
755
 
@@ -821,10 +850,12 @@ function render_self(gobj)
821
850
  }
822
851
 
823
852
  if(!view) {
824
- priv.$body.appendChild(createElement2(
853
+ let $empty = createElement2(
825
854
  ["div", {class: "NODE_EMPTY", i18n: "nothing here yet"},
826
855
  "Nothing here yet."]
827
- ));
856
+ );
857
+ priv.$body.appendChild($empty);
858
+ translate_own_dom(gobj, $empty);
828
859
  }
829
860
  }
830
861
 
@@ -917,6 +948,7 @@ function render_path(gobj, $where)
917
948
  let $nav = gobj_read_attr(nav, "$container");
918
949
  if($nav) {
919
950
  $where.appendChild($nav);
951
+ translate_own_dom(gobj, $nav);
920
952
  }
921
953
  priv.navs.push(nav);
922
954
  i++;
@@ -0,0 +1,362 @@
1
+ /***********************************************************************
2
+ * c_yui_service_view.js
3
+ *
4
+ * Mounting a view that TALKS TO A BACKEND.
5
+ *
6
+ * THE PROBLEM
7
+ * -----------
8
+ * A view that asks the backend for data does it with
9
+ * gobj_command(remote, "...", kw, src = itself)
10
+ * and C_IEVENT_CLI routes the answer back with
11
+ * gobj_find_service(gobj_name(src))
12
+ * which only finds REGISTERED SERVICES.
13
+ *
14
+ * Neither host creates one: C_YUI_SHELL mounts a route's view with
15
+ * `gobj_create()` and C_YUI_NODE with `gobj_create_pure_child()`.
16
+ * So a backend-talking view mounted directly never receives a single
17
+ * answer — and not quietly: the ievent logs "service not found" once
18
+ * per answer while the view sits empty forever.
19
+ *
20
+ * There is a second half. A route's `target.kw` is STATIC JSON, so
21
+ * it cannot carry the live transport pointer the view needs. It has
22
+ * to be resolved at mount time and injected.
23
+ *
24
+ * THE PIECE
25
+ * ---------
26
+ * Both halves had been written four separate times, in three repos,
27
+ * each wrapper saying the same thing in its own header. They are
28
+ * here now, in two shapes, because the four callers are not alike:
29
+ *
30
+ * yui_mount_service_view(host, spec) — the helper. For a wrapper
31
+ * that has its OWN extras on top (bridging url segments to the
32
+ * hosted view, rebinding it when a connection drops). Those
33
+ * extras are app or route logic and do NOT belong here; the
34
+ * wrapper keeps them and drops only the boilerplate.
35
+ *
36
+ * C_YUI_SERVICE_VIEW — the gclass, for a route with NO extras:
37
+ * declare it in the route/node and name the view it hosts. It
38
+ * is the helper plus a lifecycle, nothing more.
39
+ *
40
+ * CONTRACT of the hosted gclass:
41
+ * - declares the attr the transport is injected under
42
+ * (`gobj_remote_yuno` by default),
43
+ * - builds its `$container` in mt_create (re-exposed as the
44
+ * host's, which is what the shell/node mounts),
45
+ * - flags EVF_PUBLIC_EVENT on what arrives from the backend
46
+ * (EV_MT_COMMAND_ANSWER and anything the remote publishes): the
47
+ * ievent drops events that are not public.
48
+ *
49
+ * NAMES: the service name must be UNIQUE per mount. A duplicate is
50
+ * not fatal and that is the danger — gobj-js logs "service ALREADY
51
+ * REGISTERED. Will be UPDATED" and REBINDS the name, so the answers
52
+ * of one view would land in the other. Derive it from the route (or
53
+ * from whatever else makes the mount unique: a connection id, a
54
+ * workspace) and never from the gclass alone.
55
+ *
56
+ * Copyright (c) 2026, ArtGins.
57
+ * All Rights Reserved.
58
+ ***********************************************************************/
59
+ import {
60
+ SDATA,
61
+ SDATA_END,
62
+ data_type_t,
63
+ gclass_create,
64
+ gclass_find_by_name,
65
+ log_error,
66
+ gobj_parent,
67
+ gobj_read_attr,
68
+ gobj_read_str_attr,
69
+ gobj_read_pointer_attr,
70
+ gobj_write_attr,
71
+ gobj_subscribe_event,
72
+ gobj_find_service,
73
+ gobj_create_service,
74
+ gobj_start,
75
+ gobj_stop,
76
+ gobj_is_running,
77
+ gobj_short_name,
78
+ is_gobj,
79
+ is_string,
80
+ empty_string,
81
+ createElement2,
82
+ } from "@yuneta/gobj-js";
83
+
84
+
85
+ /***************************************************************
86
+ * Constants
87
+ ***************************************************************/
88
+ const GCLASS_NAME = "C_YUI_SERVICE_VIEW";
89
+
90
+ /* What the four wrappers all did: the app's single transport, injected
91
+ * under the attr every backend-talking gclass in this library declares. */
92
+ const DEFAULT_TRANSPORT_SERVICE = "__remote_service__";
93
+ const DEFAULT_TRANSPORT_ATTR = "gobj_remote_yuno";
94
+
95
+
96
+ /***************************************************************
97
+ * Data
98
+ ***************************************************************/
99
+ const attrs_table = [
100
+ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
101
+
102
+ SDATA(data_type_t.DTP_STRING, "view_gclass", 0, "", "GClass of the hosted view (must be registered)"),
103
+ SDATA(data_type_t.DTP_STRING, "service_name", 0, "", "Service name of the hosted view — UNIQUE per mount"),
104
+ SDATA(data_type_t.DTP_JSON, "view_kw", 0, null, "kw passed to the hosted view"),
105
+ SDATA(data_type_t.DTP_STRING, "transport_service", 0, DEFAULT_TRANSPORT_SERVICE, "Service name of the live transport"),
106
+ SDATA(data_type_t.DTP_STRING, "transport_attr", 0, DEFAULT_TRANSPORT_ATTR, "Attr of the hosted view the transport is injected under"),
107
+ SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement"),
108
+ SDATA_END()
109
+ ];
110
+
111
+ let PRIVATE_DATA = {
112
+ view: null,
113
+ };
114
+
115
+ let __gclass__ = null;
116
+
117
+
118
+
119
+
120
+ /***************************
121
+ * Public helper
122
+ ***************************/
123
+
124
+
125
+
126
+
127
+ /************************************************************
128
+ * Create `spec.gclass` as a NAMED SERVICE under `host`, with the
129
+ * live transport injected, and return it (null on failure, error
130
+ * already logged). The caller owns what happens next — starting
131
+ * it, exposing its DOM, bridging routes into it.
132
+ *
133
+ * spec = {
134
+ * gclass, // string, required — must be registered
135
+ * name, // string, required — service name, unique per mount
136
+ * kw, // object, optional — the view's kw
137
+ * transport, // gobj | service name | omitted:
138
+ * // a gobj is used as-is (the caller already
139
+ * // resolved it — e.g. per connection), a string
140
+ * // is looked up with gobj_find_service(), and
141
+ * // omitted means "__remote_service__".
142
+ * // null DISABLES injection: for a hosted view
143
+ * // that takes its transport some other way.
144
+ * transport_attr // string, optional — default "gobj_remote_yuno"
145
+ * }
146
+ ************************************************************/
147
+ function yui_mount_service_view(host, spec)
148
+ {
149
+ if(!host || !is_gobj(host) || !spec) {
150
+ log_error(`${GCLASS_NAME}: yui_mount_service_view without host or spec`);
151
+ return null;
152
+ }
153
+
154
+ const gclass = spec.gclass;
155
+ const name = spec.name;
156
+
157
+ if(empty_string(gclass) || empty_string(name)) {
158
+ log_error(
159
+ `${gobj_short_name(host)}: mounting a service view needs gclass and name`
160
+ );
161
+ return null;
162
+ }
163
+ if(!gclass_find_by_name(gclass)) {
164
+ log_error(`${gobj_short_name(host)}: gclass '${gclass}' is not registered`);
165
+ return null;
166
+ }
167
+
168
+ let kw = Object.assign({}, spec.kw || {});
169
+
170
+ /* `undefined` means "the app's single transport"; an explicit null
171
+ * means "do not inject one". They are NOT the same, hence the
172
+ * hasOwnProperty test instead of a truthiness check. */
173
+ let transport = Object.prototype.hasOwnProperty.call(spec, "transport")
174
+ ? spec.transport
175
+ : DEFAULT_TRANSPORT_SERVICE;
176
+
177
+ if(transport !== null) {
178
+ if(is_string(transport)) {
179
+ const service_name = transport;
180
+ transport = gobj_find_service(service_name);
181
+ if(!transport) {
182
+ log_error(
183
+ `${gobj_short_name(host)}: transport service ` +
184
+ `'${service_name}' not found`
185
+ );
186
+ return null;
187
+ }
188
+ }
189
+ if(!is_gobj(transport)) {
190
+ log_error(`${gobj_short_name(host)}: transport is not a gobj`);
191
+ return null;
192
+ }
193
+ kw[spec.transport_attr || DEFAULT_TRANSPORT_ATTR] = transport;
194
+ }
195
+
196
+ const view = gobj_create_service(name, gclass, kw, host);
197
+ if(!view) {
198
+ log_error(`${gobj_short_name(host)}: cannot create '${gclass}' as '${name}'`);
199
+ return null;
200
+ }
201
+ return view;
202
+ }
203
+
204
+ /************************************************************
205
+ * The hosted view's $container, re-exposed as the host's — the
206
+ * shell/node mounts the HOST, so without this nothing is drawn.
207
+ * Never returns without setting one: an empty div beats a blank
208
+ * screen with the reason only in the console.
209
+ ************************************************************/
210
+ function expose_view_container(host, view)
211
+ {
212
+ let $c = view ? gobj_read_attr(view, "$container") : null;
213
+ if(!$c) {
214
+ log_error(
215
+ `${gobj_short_name(host)}: hosted view does not expose $container ` +
216
+ `— check its mt_create`
217
+ );
218
+ $c = createElement2(["div", {}, ""]);
219
+ }
220
+ gobj_write_attr(host, "$container", $c);
221
+ return $c;
222
+ }
223
+
224
+
225
+
226
+
227
+ /***************************
228
+ * Framework Methods
229
+ ***************************/
230
+
231
+
232
+
233
+
234
+ /***************************************************************
235
+ * Framework Method: Create
236
+ ***************************************************************/
237
+ function mt_create(gobj)
238
+ {
239
+ let priv = gobj.priv;
240
+
241
+ /*
242
+ * CHILD subscription model
243
+ */
244
+ let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
245
+ if(!subscriber) {
246
+ subscriber = gobj_parent(gobj);
247
+ }
248
+ gobj_subscribe_event(gobj, null, {}, subscriber);
249
+
250
+ priv.view = yui_mount_service_view(gobj, {
251
+ gclass: gobj_read_str_attr(gobj, "view_gclass"),
252
+ name: gobj_read_str_attr(gobj, "service_name"),
253
+ kw: gobj_read_attr(gobj, "view_kw") || {},
254
+ transport: gobj_read_str_attr(gobj, "transport_service"),
255
+ transport_attr: gobj_read_str_attr(gobj, "transport_attr")
256
+ });
257
+
258
+ expose_view_container(gobj, priv.view);
259
+ }
260
+
261
+ /***************************************************************
262
+ * Framework Method: Start
263
+ *
264
+ * The view starts HERE and not in mt_create, so its first
265
+ * request goes out when the host shows it.
266
+ ***************************************************************/
267
+ function mt_start(gobj)
268
+ {
269
+ const view = gobj.priv.view;
270
+ if(view && !gobj_is_running(view)) {
271
+ gobj_start(view);
272
+ }
273
+ }
274
+
275
+ /***************************************************************
276
+ * Framework Method: Stop
277
+ ***************************************************************/
278
+ function mt_stop(gobj)
279
+ {
280
+ const view = gobj.priv.view;
281
+ if(view && gobj_is_running(view)) {
282
+ gobj_stop(view);
283
+ }
284
+ }
285
+
286
+ /***************************************************************
287
+ * Framework Method: Destroy
288
+ *
289
+ * The view is a SERVICE created with this gobj as its parent, so
290
+ * gobj_destroy cascades onto it — and deregisters its name. Do
291
+ * NOT destroy it again here.
292
+ ***************************************************************/
293
+ function mt_destroy(gobj)
294
+ {
295
+ let priv = gobj.priv;
296
+
297
+ priv.view = null;
298
+
299
+ const $c = gobj_read_attr(gobj, "$container");
300
+ if($c && $c.parentNode) {
301
+ $c.parentNode.removeChild($c);
302
+ }
303
+ gobj_write_attr(gobj, "$container", null);
304
+ }
305
+
306
+
307
+
308
+
309
+ /***************************
310
+ * FSM
311
+ ***************************/
312
+
313
+
314
+
315
+
316
+ const gmt = {
317
+ mt_create: mt_create,
318
+ mt_start: mt_start,
319
+ mt_stop: mt_stop,
320
+ mt_destroy: mt_destroy
321
+ };
322
+
323
+ function create_gclass(gclass_name)
324
+ {
325
+ if(__gclass__) {
326
+ log_error(`GClass ALREADY created: ${gclass_name}`);
327
+ return -1;
328
+ }
329
+
330
+ const states = [
331
+ ["ST_IDLE", []]
332
+ ];
333
+
334
+ const event_types = [];
335
+
336
+ __gclass__ = gclass_create(
337
+ gclass_name,
338
+ event_types,
339
+ states,
340
+ gmt,
341
+ 0,
342
+ attrs_table,
343
+ PRIVATE_DATA,
344
+ 0,
345
+ 0,
346
+ 0,
347
+ 0
348
+ );
349
+
350
+ return __gclass__ ? 0 : -1;
351
+ }
352
+
353
+ function register_c_yui_service_view()
354
+ {
355
+ return create_gclass(GCLASS_NAME);
356
+ }
357
+
358
+ export {
359
+ register_c_yui_service_view,
360
+ yui_mount_service_view,
361
+ expose_view_container,
362
+ };
@@ -1542,10 +1542,8 @@ function build_view_gobj(gobj, entry, route, stage)
1542
1542
  * (synthesized "cards" C_YUI_NAV) is SHELL-owned DOM built after
1543
1543
  * the host's one-shot refresh_language — apply the registered
1544
1544
  * translator, same policy as lazily-built dropdown panels. */
1545
- let priv = gobj.priv;
1546
- if(priv && typeof priv.translator === "function" &&
1547
- target.gclass === "C_YUI_NAV") {
1548
- refresh_language($view, priv.translator);
1545
+ if(target.gclass === "C_YUI_NAV") {
1546
+ yui_shell_translate(gobj, $view);
1549
1547
  }
1550
1548
  return view;
1551
1549
  }
@@ -2050,9 +2048,7 @@ function open_toolbar_dropdown(gobj, item, action, $trigger)
2050
2048
  priv.layers.popup.appendChild($panel);
2051
2049
 
2052
2050
  /* Translate the lazily-built panel (see the note above). */
2053
- if(typeof priv.translator === "function") {
2054
- refresh_language($panel, priv.translator);
2055
- }
2051
+ yui_shell_translate(gobj, $panel);
2056
2052
 
2057
2053
  /* Click-outside (capture-phase mousedown) closes the dropdown.
2058
2054
  * Capture phase so a click on a sibling toolbar trigger lands
@@ -2857,10 +2853,7 @@ function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
2857
2853
  gobj_send_event(view, "EV_SET_ITEMS", {items: items}, shell_gobj);
2858
2854
  /* EV_SET_ITEMS rebuilt the DOM: re-apply the translator
2859
2855
  * (shell-owned DOM, same policy as build_view_gobj). */
2860
- let $view = gobj_read_attr(view, "$container");
2861
- if($view && typeof priv.translator === "function") {
2862
- refresh_language($view, priv.translator);
2863
- }
2856
+ yui_shell_translate(shell_gobj, gobj_read_attr(view, "$container"));
2864
2857
  }
2865
2858
  }
2866
2859
  }
@@ -3265,9 +3258,10 @@ function yui_shell_refresh_avatars(shell_gobj)
3265
3258
  * Register the host's i18n translator (a t-function:
3266
3259
  * key => translated string). The host still translates the
3267
3260
  * static shell tree itself via refresh_language($container, t);
3268
- * this is only so the shell can translate DOM it builds LAZILY
3269
- * and OUTSIDE $containertoday the toolbar dropdown panel.
3270
- * Optional: with no translator the panel renders raw keys
3261
+ * this is only so the shell and the library components that
3262
+ * build DOM under it can translate DOM built LAZILY, after
3263
+ * that one-shot pass and often outside $container.
3264
+ * Optional: with no translator such DOM renders raw keys
3271
3265
  * (the previous behaviour).
3272
3266
  ************************************************************/
3273
3267
  function yui_shell_set_translator(shell_gobj, t)
@@ -3279,6 +3273,39 @@ function yui_shell_set_translator(shell_gobj, t)
3279
3273
  priv.translator = (typeof t === "function") ? t : null;
3280
3274
  }
3281
3275
 
3276
+ /************************************************************
3277
+ * Apply the registered translator to a FRESHLY BUILT subtree.
3278
+ *
3279
+ * Carrying the `i18n` key on a node is not enough for it to
3280
+ * render translated: the node is born holding the raw English
3281
+ * key, and the host's refresh_language() passes walk what
3282
+ * ALREADY exists — the shell tree at start up, document.body on
3283
+ * a language switch. Anything built after that (a dropdown
3284
+ * panel, a nav a node renders when you walk into it) is reached
3285
+ * by neither, so it renders the key: lower-case English that
3286
+ * never changes language, i.e. exactly what a MISSING key looks
3287
+ * like.
3288
+ *
3289
+ * The division of labour, unchanged: LIBRARY-built DOM is
3290
+ * translated through here; APP view gclasses translate their own
3291
+ * DOM (they own a `t` — see mount_view).
3292
+ *
3293
+ * Silent no-op with no shell or no translator: an app that never
3294
+ * registered one keeps the previous behaviour instead of losing
3295
+ * its chrome.
3296
+ ************************************************************/
3297
+ function yui_shell_translate(shell_gobj, $el)
3298
+ {
3299
+ if(!$el || !shell_gobj || !is_gobj(shell_gobj)) {
3300
+ return;
3301
+ }
3302
+ let priv = shell_gobj.priv;
3303
+ if(!priv || typeof priv.translator !== "function") {
3304
+ return;
3305
+ }
3306
+ refresh_language($el, priv.translator);
3307
+ }
3308
+
3282
3309
  /************************************************************
3283
3310
  * The app switched the language: re-translate the whole document (every
3284
3311
  * node carrying data-i18n / data-i18n-title / data-i18n-aria-label) and
@@ -3383,6 +3410,7 @@ export {
3383
3410
  yui_shell_set_avatar_provider,
3384
3411
  yui_shell_refresh_avatars,
3385
3412
  yui_shell_set_translator,
3413
+ yui_shell_translate,
3386
3414
  yui_shell_language_changed,
3387
3415
  yui_shell_set_connection_state,
3388
3416
  yui_shell_set_toolbar_item_icon,