@yuneta/gobj-ui 7.23.39 → 7.23.41

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.
@@ -44,6 +44,75 @@ maplibre_gl = __toESM(maplibre_gl, 1);
44
44
  let uplot = require("uplot");
45
45
  uplot = __toESM(uplot, 1);
46
46
  let _antv_g6 = require("@antv/g6");
47
+ //#region src/bulma_tint.js
48
+ /***********************************************************************
49
+ * bulma_tint.js
50
+ *
51
+ * Bulma's "soften this colour" modifier, applied so that it cannot
52
+ * be applied alone.
53
+ *
54
+ * WHY THIS IS A FUNCTION AND NOT A STRING. 'light' is one of
55
+ * Bulma's colours, so `is-light` is BOTH a colour and the modifier
56
+ * that softens another one. A compound selector may match the same
57
+ * class twice, so `.tag.is-light.is-light` matches an element that
58
+ * carries the class ONCE -- and that rule sets the text to
59
+ * var(--bulma-light-light-invert-l), WHICH BULMA NEVER DEFINES:
60
+ * referenced four times in bulma.css, declared zero times. The
61
+ * colour declaration is then invalid while the background still
62
+ * resolves, so what is left on screen is a pill with no readable
63
+ * label.
64
+ *
65
+ * Two selectors of Bulma 1 reach that undefined variable:
66
+ *
67
+ * .tag.is-light.is-light
68
+ * .notification.is-light.is-light
69
+ *
70
+ * It cost three unreadable chips in the schema editor -- the topic
71
+ * count, the schema version, and every column flag with no colour of
72
+ * its own ('writable', 'image', 'wild'...). They read as empty
73
+ * pills, which looks like a bug in the DATA and is a missing CSS
74
+ * variable.
75
+ *
76
+ * The neutral chip is a bare element with no modifier at all: it
77
+ * takes --bulma-background-l and --bulma-text-l, follows the theme
78
+ * and stays legible in both.
79
+ *
80
+ * Copyright (c) 2026, ArtGins.
81
+ * All Rights Reserved.
82
+ ***********************************************************************/
83
+ var TINTABLE = [
84
+ "primary",
85
+ "link",
86
+ "info",
87
+ "success",
88
+ "warning",
89
+ "danger",
90
+ "black",
91
+ "dark",
92
+ "white",
93
+ "text",
94
+ "ghost"
95
+ ];
96
+ /***************************************************************
97
+ * A softened colour, or nothing.
98
+ *
99
+ * yui_tint("warning") -> "is-warning is-light"
100
+ * yui_tint("is-warning") -> "is-warning is-light"
101
+ * yui_tint("light") -> "" (there is nothing to soften)
102
+ * yui_tint("") -> ""
103
+ * yui_tint(undefined) -> ""
104
+ *
105
+ * Anything it does not recognise comes back empty rather than being
106
+ * passed through: a class this function cannot vouch for is exactly the
107
+ * case it exists to stop.
108
+ ***************************************************************/
109
+ function yui_tint(colour) {
110
+ if (typeof colour !== "string") return "";
111
+ const name = colour.trim().replace(/^is-/, "");
112
+ if (TINTABLE.indexOf(name) < 0) return "";
113
+ return `is-${name} is-light`;
114
+ }
115
+ //#endregion
47
116
  //#region src/shell_focus_trap.js
48
117
  /***********************************************************************
49
118
  * shell_focus_trap.js
@@ -2858,7 +2927,7 @@ function show_notification(shell, kind, message, opts) {
2858
2927
  let $note = (0, _yuneta_gobj_js.createElement2)([
2859
2928
  "div",
2860
2929
  {
2861
- class: `TOAST notification yui-notification is-${kind} is-light`,
2930
+ class: `TOAST notification yui-notification ${yui_tint(kind)}`,
2862
2931
  role: kind === "danger" ? "alert" : "status"
2863
2932
  },
2864
2933
  [["button", {
@@ -10921,10 +10990,6 @@ function make_toolbar$5(gobj) {
10921
10990
  "span",
10922
10991
  { class: "JSON_GRAPH_FIND_COUNT" },
10923
10992
  ""
10924
- ], [
10925
- "span",
10926
- { i18n: "matches" },
10927
- "matches"
10928
10993
  ]]
10929
10994
  ]]);
10930
10995
  let center_items = [];
@@ -11450,7 +11515,7 @@ function update_find_result$1(gobj) {
11450
11515
  priv.$find_count.textContent = "";
11451
11516
  return;
11452
11517
  }
11453
- priv.$find_count.textContent = String(priv.match_count);
11518
+ priv.$find_count.textContent = (0, i18next.t)("matches", { count: priv.match_count });
11454
11519
  priv.$find_result.classList.remove("is-hidden");
11455
11520
  }
11456
11521
  /************************************************************
@@ -17368,10 +17433,6 @@ function make_find_items(gobj) {
17368
17433
  "span",
17369
17434
  { class: "GOBJ_TREE_FIND_COUNT" },
17370
17435
  ""
17371
- ], [
17372
- "span",
17373
- { i18n: "matches" },
17374
- (0, i18next.t)("matches")
17375
17436
  ]]
17376
17437
  ]];
17377
17438
  }
@@ -17404,7 +17465,7 @@ function update_find_result(gobj) {
17404
17465
  priv.$find_count.textContent = "";
17405
17466
  return;
17406
17467
  }
17407
- priv.$find_count.textContent = String(priv.match_count);
17468
+ priv.$find_count.textContent = (0, i18next.t)("matches", { count: priv.match_count });
17408
17469
  priv.$find_result.classList.remove("is-hidden");
17409
17470
  }
17410
17471
  function make_toolbar$1(gobj) {
@@ -18650,6 +18711,7 @@ function ac_language_changed$3(gobj, event, kw, src) {
18650
18711
  if ($container) (0, _yuneta_gobj_js.refresh_language)($container, i18next.t);
18651
18712
  hide_popover(gobj);
18652
18713
  refresh_tree(gobj);
18714
+ update_find_result(gobj);
18653
18715
  return 0;
18654
18716
  }
18655
18717
  /***************************
@@ -20164,11 +20226,7 @@ function build_body(shell, t) {
20164
20226
  let $count = (0, _yuneta_gobj_js.createElement2)([
20165
20227
  "span",
20166
20228
  { class: "ROUTEMAP_COUNT is-size-7 has-text-grey is-hidden" },
20167
- [$count_n, [
20168
- "span",
20169
- { i18n: "matches" },
20170
- "matches"
20171
- ]]
20229
+ [$count_n]
20172
20230
  ]);
20173
20231
  let $refs_input = (0, _yuneta_gobj_js.createElement2)(["input", {
20174
20232
  class: "ROUTEMAP_REFS_INPUT",
@@ -20206,7 +20264,7 @@ function build_body(shell, t) {
20206
20264
  if ($root_li) filter_li($root_li, q, false, __show_refs__);
20207
20265
  if (q === "") $count.classList.add("is-hidden");
20208
20266
  else {
20209
- $count_n.textContent = $tree.querySelectorAll(".ROUTEMAP_MATCH").length;
20267
+ $count_n.textContent = t("matches", { count: $tree.querySelectorAll(".ROUTEMAP_MATCH").length });
20210
20268
  $count.classList.remove("is-hidden");
20211
20269
  }
20212
20270
  };
@@ -58140,10 +58198,6 @@ function make_toolbar(gobj) {
58140
58198
  "span",
58141
58199
  { class: "GRAPH_FIND_COUNT" },
58142
58200
  ""
58143
- ], [
58144
- "span",
58145
- { i18n: "matches" },
58146
- "matches"
58147
58201
  ]]
58148
58202
  ]]
58149
58203
  ],
@@ -59199,7 +59253,7 @@ function ac_find_result(gobj, event, kw, src) {
59199
59253
  $count.textContent = "";
59200
59254
  return 0;
59201
59255
  }
59202
- $count.textContent = String(kw && kw.matches || 0);
59256
+ $count.textContent = (0, i18next.t)("matches", { count: kw && kw.matches || 0 });
59203
59257
  $result.classList.remove("is-hidden");
59204
59258
  return 0;
59205
59259
  }
@@ -62428,7 +62482,7 @@ function open_card(gobj, logical, title, icon, tags, event, kw) {
62428
62482
  let $tags = tags.map((tag) => {
62429
62483
  return [
62430
62484
  "span",
62431
- { class: `${logical}_TAG tag ${tag.style || "is-light"}` },
62485
+ { class: `${logical}_TAG tag ${tag.style || ""}`.trimEnd() },
62432
62486
  tag.i18n ? [[
62433
62487
  "span",
62434
62488
  { i18n: tag.i18n },
@@ -62575,7 +62629,7 @@ function render_topics(gobj, $body) {
62575
62629
  topic.system_topic ? [
62576
62630
  "span",
62577
62631
  {
62578
- class: "SCHEMA_TOPIC_SYSTEM tag is-light ml-2",
62632
+ class: "SCHEMA_TOPIC_SYSTEM tag ml-2",
62579
62633
  i18n: "system"
62580
62634
  },
62581
62635
  (0, i18next.t)("system")
@@ -62924,7 +62978,7 @@ function column_row(gobj, topic, col, index, readonly, twice) {
62924
62978
  let $flags = flags.map((flag) => {
62925
62979
  return [
62926
62980
  "span",
62927
- { class: `SCHEMA_COL_FLAG tag is-light ${flag_style(flag)}` },
62981
+ { class: `SCHEMA_COL_FLAG tag ${yui_tint(flag_style(flag))}`.trimEnd() },
62928
62982
  `${flag}`
62929
62983
  ];
62930
62984
  });
@@ -39,6 +39,75 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
39
39
  enumerable: true
40
40
  }) : target, mod));
41
41
  //#endregion
42
+ //#region src/bulma_tint.js
43
+ /***********************************************************************
44
+ * bulma_tint.js
45
+ *
46
+ * Bulma's "soften this colour" modifier, applied so that it cannot
47
+ * be applied alone.
48
+ *
49
+ * WHY THIS IS A FUNCTION AND NOT A STRING. 'light' is one of
50
+ * Bulma's colours, so `is-light` is BOTH a colour and the modifier
51
+ * that softens another one. A compound selector may match the same
52
+ * class twice, so `.tag.is-light.is-light` matches an element that
53
+ * carries the class ONCE -- and that rule sets the text to
54
+ * var(--bulma-light-light-invert-l), WHICH BULMA NEVER DEFINES:
55
+ * referenced four times in bulma.css, declared zero times. The
56
+ * colour declaration is then invalid while the background still
57
+ * resolves, so what is left on screen is a pill with no readable
58
+ * label.
59
+ *
60
+ * Two selectors of Bulma 1 reach that undefined variable:
61
+ *
62
+ * .tag.is-light.is-light
63
+ * .notification.is-light.is-light
64
+ *
65
+ * It cost three unreadable chips in the schema editor -- the topic
66
+ * count, the schema version, and every column flag with no colour of
67
+ * its own ('writable', 'image', 'wild'...). They read as empty
68
+ * pills, which looks like a bug in the DATA and is a missing CSS
69
+ * variable.
70
+ *
71
+ * The neutral chip is a bare element with no modifier at all: it
72
+ * takes --bulma-background-l and --bulma-text-l, follows the theme
73
+ * and stays legible in both.
74
+ *
75
+ * Copyright (c) 2026, ArtGins.
76
+ * All Rights Reserved.
77
+ ***********************************************************************/
78
+ var TINTABLE = [
79
+ "primary",
80
+ "link",
81
+ "info",
82
+ "success",
83
+ "warning",
84
+ "danger",
85
+ "black",
86
+ "dark",
87
+ "white",
88
+ "text",
89
+ "ghost"
90
+ ];
91
+ /***************************************************************
92
+ * A softened colour, or nothing.
93
+ *
94
+ * yui_tint("warning") -> "is-warning is-light"
95
+ * yui_tint("is-warning") -> "is-warning is-light"
96
+ * yui_tint("light") -> "" (there is nothing to soften)
97
+ * yui_tint("") -> ""
98
+ * yui_tint(undefined) -> ""
99
+ *
100
+ * Anything it does not recognise comes back empty rather than being
101
+ * passed through: a class this function cannot vouch for is exactly the
102
+ * case it exists to stop.
103
+ ***************************************************************/
104
+ function yui_tint(colour) {
105
+ if (typeof colour !== "string") return "";
106
+ const name = colour.trim().replace(/^is-/, "");
107
+ if (TINTABLE.indexOf(name) < 0) return "";
108
+ return `is-${name} is-light`;
109
+ }
110
+ //#endregion
42
111
  //#region src/shell_focus_trap.js
43
112
  /***********************************************************************
44
113
  * shell_focus_trap.js
@@ -2853,7 +2922,7 @@ function show_notification(shell, kind, message, opts) {
2853
2922
  let $note = createElement2([
2854
2923
  "div",
2855
2924
  {
2856
- class: `TOAST notification yui-notification is-${kind} is-light`,
2925
+ class: `TOAST notification yui-notification ${yui_tint(kind)}`,
2857
2926
  role: kind === "danger" ? "alert" : "status"
2858
2927
  },
2859
2928
  [["button", {
@@ -10916,10 +10985,6 @@ function make_toolbar$5(gobj) {
10916
10985
  "span",
10917
10986
  { class: "JSON_GRAPH_FIND_COUNT" },
10918
10987
  ""
10919
- ], [
10920
- "span",
10921
- { i18n: "matches" },
10922
- "matches"
10923
10988
  ]]
10924
10989
  ]]);
10925
10990
  let center_items = [];
@@ -11445,7 +11510,7 @@ function update_find_result$1(gobj) {
11445
11510
  priv.$find_count.textContent = "";
11446
11511
  return;
11447
11512
  }
11448
- priv.$find_count.textContent = String(priv.match_count);
11513
+ priv.$find_count.textContent = t("matches", { count: priv.match_count });
11449
11514
  priv.$find_result.classList.remove("is-hidden");
11450
11515
  }
11451
11516
  /************************************************************
@@ -17363,10 +17428,6 @@ function make_find_items(gobj) {
17363
17428
  "span",
17364
17429
  { class: "GOBJ_TREE_FIND_COUNT" },
17365
17430
  ""
17366
- ], [
17367
- "span",
17368
- { i18n: "matches" },
17369
- t("matches")
17370
17431
  ]]
17371
17432
  ]];
17372
17433
  }
@@ -17399,7 +17460,7 @@ function update_find_result(gobj) {
17399
17460
  priv.$find_count.textContent = "";
17400
17461
  return;
17401
17462
  }
17402
- priv.$find_count.textContent = String(priv.match_count);
17463
+ priv.$find_count.textContent = t("matches", { count: priv.match_count });
17403
17464
  priv.$find_result.classList.remove("is-hidden");
17404
17465
  }
17405
17466
  function make_toolbar$1(gobj) {
@@ -18645,6 +18706,7 @@ function ac_language_changed$3(gobj, event, kw, src) {
18645
18706
  if ($container) refresh_language($container, t);
18646
18707
  hide_popover(gobj);
18647
18708
  refresh_tree(gobj);
18709
+ update_find_result(gobj);
18648
18710
  return 0;
18649
18711
  }
18650
18712
  /***************************
@@ -20159,11 +20221,7 @@ function build_body(shell, t) {
20159
20221
  let $count = createElement2([
20160
20222
  "span",
20161
20223
  { class: "ROUTEMAP_COUNT is-size-7 has-text-grey is-hidden" },
20162
- [$count_n, [
20163
- "span",
20164
- { i18n: "matches" },
20165
- "matches"
20166
- ]]
20224
+ [$count_n]
20167
20225
  ]);
20168
20226
  let $refs_input = createElement2(["input", {
20169
20227
  class: "ROUTEMAP_REFS_INPUT",
@@ -20201,7 +20259,7 @@ function build_body(shell, t) {
20201
20259
  if ($root_li) filter_li($root_li, q, false, __show_refs__);
20202
20260
  if (q === "") $count.classList.add("is-hidden");
20203
20261
  else {
20204
- $count_n.textContent = $tree.querySelectorAll(".ROUTEMAP_MATCH").length;
20262
+ $count_n.textContent = t("matches", { count: $tree.querySelectorAll(".ROUTEMAP_MATCH").length });
20205
20263
  $count.classList.remove("is-hidden");
20206
20264
  }
20207
20265
  };
@@ -58135,10 +58193,6 @@ function make_toolbar(gobj) {
58135
58193
  "span",
58136
58194
  { class: "GRAPH_FIND_COUNT" },
58137
58195
  ""
58138
- ], [
58139
- "span",
58140
- { i18n: "matches" },
58141
- "matches"
58142
58196
  ]]
58143
58197
  ]]
58144
58198
  ],
@@ -59194,7 +59248,7 @@ function ac_find_result(gobj, event, kw, src) {
59194
59248
  $count.textContent = "";
59195
59249
  return 0;
59196
59250
  }
59197
- $count.textContent = String(kw && kw.matches || 0);
59251
+ $count.textContent = t("matches", { count: kw && kw.matches || 0 });
59198
59252
  $result.classList.remove("is-hidden");
59199
59253
  return 0;
59200
59254
  }
@@ -62423,7 +62477,7 @@ function open_card(gobj, logical, title, icon, tags, event, kw) {
62423
62477
  let $tags = tags.map((tag) => {
62424
62478
  return [
62425
62479
  "span",
62426
- { class: `${logical}_TAG tag ${tag.style || "is-light"}` },
62480
+ { class: `${logical}_TAG tag ${tag.style || ""}`.trimEnd() },
62427
62481
  tag.i18n ? [[
62428
62482
  "span",
62429
62483
  { i18n: tag.i18n },
@@ -62570,7 +62624,7 @@ function render_topics(gobj, $body) {
62570
62624
  topic.system_topic ? [
62571
62625
  "span",
62572
62626
  {
62573
- class: "SCHEMA_TOPIC_SYSTEM tag is-light ml-2",
62627
+ class: "SCHEMA_TOPIC_SYSTEM tag ml-2",
62574
62628
  i18n: "system"
62575
62629
  },
62576
62630
  t("system")
@@ -62919,7 +62973,7 @@ function column_row(gobj, topic, col, index, readonly, twice) {
62919
62973
  let $flags = flags.map((flag) => {
62920
62974
  return [
62921
62975
  "span",
62922
- { class: `SCHEMA_COL_FLAG tag is-light ${flag_style(flag)}` },
62976
+ { class: `SCHEMA_COL_FLAG tag ${yui_tint(flag_style(flag))}`.trimEnd() },
62923
62977
  `${flag}`
62924
62978
  ];
62925
62979
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.39",
3
+ "version": "7.23.41",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -0,0 +1,160 @@
1
+ /***********************************************************************
2
+ * bulma_is_light.test.js
3
+ *
4
+ * `is-light` MUST NEVER BE THE ONLY BULMA COLOUR ON AN ELEMENT.
5
+ *
6
+ * 'light' is one of Bulma's colours, so `is-light` is both a colour
7
+ * and the "soften it" modifier. A compound selector may match the
8
+ * same class twice, so `.tag.is-light.is-light` matches an element
9
+ * that carries the class ONCE -- and that rule sets the text to
10
+ * var(--bulma-light-light-invert-l), WHICH BULMA NEVER DEFINES:
11
+ * referenced four times in bulma.css, declared zero times. The
12
+ * colour declaration is then invalid while the background still
13
+ * resolves, and what is left on screen is a pill with no readable
14
+ * label.
15
+ *
16
+ * Two selectors in Bulma 1 reach that undefined variable:
17
+ *
18
+ * .tag.is-light.is-light
19
+ * .notification.is-light.is-light
20
+ *
21
+ * It cost three unreadable chips in the schema editor -- the topic
22
+ * count, the schema version, and every column flag without a colour
23
+ * of its own ('writable', 'image', 'wild'...). They looked like
24
+ * empty pills, which reads as a rendering bug in the data and is a
25
+ * missing CSS variable.
26
+ *
27
+ * THIS IS A SOURCE SCAN AND NOT A RENDER TEST ON PURPOSE: jsdom does
28
+ * not load Bulma, so no mounted test can see the contrast. What can
29
+ * be checked is the rule that produces it.
30
+ *
31
+ * Copyright (c) 2026, ArtGins.
32
+ * All Rights Reserved.
33
+ ***********************************************************************/
34
+ import {describe, it, expect} from "vitest";
35
+ import {readdirSync, readFileSync} from "node:fs";
36
+ import {dirname, join} from "node:path";
37
+ import {fileURLToPath} from "node:url";
38
+
39
+ import {yui_tint, TINTABLE} from "./bulma_tint.js";
40
+
41
+ const SRC = dirname(fileURLToPath(import.meta.url));
42
+
43
+ /*
44
+ * The ONE file allowed to write the literal: yui_tint() emits it only
45
+ * next to a colour it has already checked against TINTABLE, and the
46
+ * colour is interpolated, so no source scan can see it. What guards
47
+ * that file is the behaviour suite at the bottom, not the scan.
48
+ */
49
+ const EMITTER = "bulma_tint.js";
50
+
51
+ /* Every Bulma colour except 'light' itself. */
52
+ const COLOURS = [
53
+ "is-primary", "is-link", "is-info", "is-success", "is-warning",
54
+ "is-danger", "is-black", "is-dark", "is-white", "is-text", "is-ghost"
55
+ ];
56
+
57
+ /*
58
+ * How far around an occurrence to look for its colour. The colour does
59
+ * not have to sit on the same line: it may come from a ternary, from a
60
+ * string concatenation split across two lines, or from a variable
61
+ * interpolated just before. A window is what covers all three without
62
+ * parsing javascript.
63
+ */
64
+ const WINDOW = 260;
65
+
66
+ function offenders(source)
67
+ {
68
+ let out = [];
69
+ let at = source.indexOf("is-light");
70
+ while(at >= 0) {
71
+ const from = Math.max(0, at - WINDOW);
72
+ const to = Math.min(source.length, at + WINDOW);
73
+ const around = source.slice(from, to);
74
+
75
+ /* A comment ABOUT the rule is not a use of it. */
76
+ const line_start = source.lastIndexOf("\n", at) + 1;
77
+ const line = source.slice(line_start, source.indexOf("\n", at));
78
+ const is_comment = /^\s*(\*|\/\/|\/\*)/.test(line);
79
+
80
+ if(!is_comment && !COLOURS.some((c) => around.includes(c))) {
81
+ out.push(line.trim());
82
+ }
83
+ at = source.indexOf("is-light", at + 1);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ describe("bulma: is-light never travels alone", () => {
89
+ const files = readdirSync(SRC).filter(
90
+ (f) => f.endsWith(".js") && !f.endsWith(".test.js") && f !== EMITTER
91
+ );
92
+
93
+ it("scans every source file of the library", () => {
94
+ expect(files.length).toBeGreaterThan(0);
95
+ });
96
+
97
+ for(let file of files) {
98
+ it(`${file} carries no colourless is-light`, () => {
99
+ const source = readFileSync(join(SRC, file), "utf-8");
100
+ expect(offenders(source)).toEqual([]);
101
+ });
102
+ }
103
+ });
104
+
105
+ describe("the scan itself", () => {
106
+ it("catches a colourless is-light", () => {
107
+ const bad = 'return ["span", {class: `SOMETHING tag is-light`}, "x"];';
108
+ expect(offenders(bad)).toHaveLength(1);
109
+ });
110
+
111
+ it("accepts one that travels with a colour", () => {
112
+ const good = 'return ["span", {class: `SOMETHING tag is-warning is-light`}, "x"];';
113
+ expect(offenders(good)).toEqual([]);
114
+ });
115
+
116
+ it("accepts a colour that arrives split across lines", () => {
117
+ const good = 'const c = `tag ${x ? "is-danger" : "is-warning"}`\n'
118
+ + ' + " is-light";';
119
+ expect(offenders(good)).toEqual([]);
120
+ });
121
+ });
122
+
123
+ describe("yui_tint", () => {
124
+ it("softens every colour that CAN be softened", () => {
125
+ for(let name of TINTABLE) {
126
+ expect(yui_tint(name)).toBe(`is-${name} is-light`);
127
+ expect(yui_tint(`is-${name}`)).toBe(`is-${name} is-light`);
128
+ }
129
+ });
130
+
131
+ it("refuses to soften 'light' with itself", () => {
132
+ expect(yui_tint("light")).toBe("");
133
+ expect(yui_tint("is-light")).toBe("");
134
+ });
135
+
136
+ it("answers nothing for nothing", () => {
137
+ expect(yui_tint("")).toBe("");
138
+ expect(yui_tint(undefined)).toBe("");
139
+ expect(yui_tint(null)).toBe("");
140
+ expect(yui_tint(7)).toBe("");
141
+ });
142
+
143
+ it("does not pass through what it cannot vouch for", () => {
144
+ /* A class this function cannot recognise is exactly the case it
145
+ * exists to stop, so it comes back empty instead of forwarded. */
146
+ expect(yui_tint("is-whatever")).toBe("");
147
+ expect(yui_tint("has-text-danger")).toBe("");
148
+ });
149
+
150
+ it("never emits is-light on its own", () => {
151
+ for(let input of TINTABLE.concat(
152
+ ["light", "is-light", "", "nope", "is-nope"]
153
+ )) {
154
+ const out = yui_tint(input);
155
+ if(out.includes("is-light")) {
156
+ expect(out).toMatch(/is-(primary|link|info|success|warning|danger|black|dark|white|text|ghost) is-light/);
157
+ }
158
+ }
159
+ });
160
+ });
@@ -0,0 +1,71 @@
1
+ /***********************************************************************
2
+ * bulma_tint.js
3
+ *
4
+ * Bulma's "soften this colour" modifier, applied so that it cannot
5
+ * be applied alone.
6
+ *
7
+ * WHY THIS IS A FUNCTION AND NOT A STRING. 'light' is one of
8
+ * Bulma's colours, so `is-light` is BOTH a colour and the modifier
9
+ * that softens another one. A compound selector may match the same
10
+ * class twice, so `.tag.is-light.is-light` matches an element that
11
+ * carries the class ONCE -- and that rule sets the text to
12
+ * var(--bulma-light-light-invert-l), WHICH BULMA NEVER DEFINES:
13
+ * referenced four times in bulma.css, declared zero times. The
14
+ * colour declaration is then invalid while the background still
15
+ * resolves, so what is left on screen is a pill with no readable
16
+ * label.
17
+ *
18
+ * Two selectors of Bulma 1 reach that undefined variable:
19
+ *
20
+ * .tag.is-light.is-light
21
+ * .notification.is-light.is-light
22
+ *
23
+ * It cost three unreadable chips in the schema editor -- the topic
24
+ * count, the schema version, and every column flag with no colour of
25
+ * its own ('writable', 'image', 'wild'...). They read as empty
26
+ * pills, which looks like a bug in the DATA and is a missing CSS
27
+ * variable.
28
+ *
29
+ * The neutral chip is a bare element with no modifier at all: it
30
+ * takes --bulma-background-l and --bulma-text-l, follows the theme
31
+ * and stays legible in both.
32
+ *
33
+ * Copyright (c) 2026, ArtGins.
34
+ * All Rights Reserved.
35
+ ***********************************************************************/
36
+
37
+ /*
38
+ * Bulma's colours, minus 'light' -- which is exactly the one that must
39
+ * never be softened with itself.
40
+ */
41
+ const TINTABLE = [
42
+ "primary", "link", "info", "success", "warning",
43
+ "danger", "black", "dark", "white", "text", "ghost"
44
+ ];
45
+
46
+ /***************************************************************
47
+ * A softened colour, or nothing.
48
+ *
49
+ * yui_tint("warning") -> "is-warning is-light"
50
+ * yui_tint("is-warning") -> "is-warning is-light"
51
+ * yui_tint("light") -> "" (there is nothing to soften)
52
+ * yui_tint("") -> ""
53
+ * yui_tint(undefined) -> ""
54
+ *
55
+ * Anything it does not recognise comes back empty rather than being
56
+ * passed through: a class this function cannot vouch for is exactly the
57
+ * case it exists to stop.
58
+ ***************************************************************/
59
+ function yui_tint(colour)
60
+ {
61
+ if(typeof colour !== "string") {
62
+ return "";
63
+ }
64
+ const name = colour.trim().replace(/^is-/, "");
65
+ if(TINTABLE.indexOf(name) < 0) {
66
+ return "";
67
+ }
68
+ return `is-${name} is-light`;
69
+ }
70
+
71
+ export {yui_tint, TINTABLE};
@@ -914,16 +914,25 @@ function make_find_items(gobj)
914
914
 
915
915
  return [
916
916
  $find_control,
917
- /* Two spans, not one string: the number is DATA and "matches"
918
- * is the word, so a language switch re-translates the half that
919
- * is a word. `display:flex` inline and NOT the `is-flex` helper
920
- * -- both Bulma helpers carry !important, so `is-hidden is-flex`
921
- * on one element is decided by stylesheet order. */
917
+ /* ONE counted span, not a number beside a word: "1 matches"
918
+ * is what two spans always produce, because nothing there can
919
+ * see the count. `t(key, {count})` picks `matches_one` for
920
+ * one and falls back to the base key for the rest, in both
921
+ * languages.
922
+ *
923
+ * And NO `i18n` attribute on it: refresh_language()
924
+ * re-translates whatever carries one by calling t() WITHOUT
925
+ * the count, which would put the plural back over the
926
+ * singular. A counted word is re-rendered by its owner
927
+ * instead -- which is why the language action below repaints
928
+ * this chip. */
929
+ /* `display:flex` inline and NOT the `is-flex` helper -- both
930
+ * Bulma helpers carry !important, so `is-hidden is-flex` on one
931
+ * element is decided by stylesheet order. */
922
932
  ['div', {class: 'GOBJ_TREE_FIND_RESULT is-hidden',
923
933
  style: 'display:flex; align-items:center; gap:.3rem; ' +
924
934
  'margin-right:.5rem; font-size:.85rem;'}, [
925
- ['span', {class: 'GOBJ_TREE_FIND_COUNT'}, ''],
926
- ['span', {i18n: 'matches'}, t('matches')]
935
+ ['span', {class: 'GOBJ_TREE_FIND_COUNT'}, '']
927
936
  ]]
928
937
  ];
929
938
  }
@@ -964,7 +973,7 @@ function update_find_result(gobj)
964
973
  priv.$find_count.textContent = "";
965
974
  return;
966
975
  }
967
- priv.$find_count.textContent = String(priv.match_count);
976
+ priv.$find_count.textContent = t("matches", {count: priv.match_count});
968
977
  priv.$find_result.classList.remove("is-hidden");
969
978
  }
970
979
 
@@ -2834,6 +2843,9 @@ function ac_language_changed(gobj, event, kw, src)
2834
2843
  }
2835
2844
  hide_popover(gobj);
2836
2845
  refresh_tree(gobj);
2846
+ /* The count chip carries a COUNTED word, which has no key for
2847
+ * refresh_language() to re-translate: its owner has to repaint it. */
2848
+ update_find_result(gobj);
2837
2849
  return 0;
2838
2850
  }
2839
2851
 
@@ -599,17 +599,25 @@ function make_toolbar(gobj)
599
599
  */
600
600
  let left_items = yui_graph_fold_items(gobj, toolbar_wide).concat([
601
601
  $find_control,
602
- /* Two spans, not one string: the number is DATA and "matches"
603
- * is the word, so a language switch re-translates the half that
604
- * is a word. `display:flex` inline and NOT the `is-flex`
605
- * helper — both Bulma helpers carry !important, so
606
- * `is-hidden is-flex` on one element is decided by stylesheet
607
- * order. */
602
+ /* ONE counted span, not a number beside a word: "1 matches"
603
+ * is what two spans always produce, because nothing there can
604
+ * see the count. `t(key, {count})` picks `matches_one` for
605
+ * one and falls back to the base key for the rest, in both
606
+ * languages.
607
+ *
608
+ * And NO `i18n` attribute on it: refresh_language()
609
+ * re-translates whatever carries one by calling t() WITHOUT
610
+ * the count, which would put the plural back over the
611
+ * singular. Nothing re-translates it, so it is repainted on
612
+ * the next find -- which is the next keystroke, and it is
613
+ * hidden while the box is empty. */
614
+ /* `display:flex` inline and NOT the `is-flex` helper — both
615
+ * Bulma helpers carry !important, so `is-hidden is-flex` on one
616
+ * element is decided by stylesheet order. */
608
617
  ['div', {class: 'JSON_GRAPH_FIND_RESULT is-hidden',
609
618
  style: 'display:flex; align-items:center; gap:.3rem; ' +
610
619
  'margin-right:.5rem; font-size:.85rem;'}, [
611
- ['span', {class: 'JSON_GRAPH_FIND_COUNT'}, ''],
612
- ['span', {i18n: 'matches'}, 'matches']
620
+ ['span', {class: 'JSON_GRAPH_FIND_COUNT'}, '']
613
621
  ]]
614
622
  ]);
615
623
  let center_items = [];
@@ -1537,7 +1545,7 @@ function update_find_result(gobj)
1537
1545
  priv.$find_count.textContent = "";
1538
1546
  return;
1539
1547
  }
1540
- priv.$find_count.textContent = String(priv.match_count);
1548
+ priv.$find_count.textContent = t("matches", {count: priv.match_count});
1541
1549
  priv.$find_result.classList.remove("is-hidden");
1542
1550
  }
1543
1551
 
@@ -109,6 +109,7 @@ import {
109
109
  is_object,
110
110
  empty_string,
111
111
  } from "@yuneta/gobj-js";
112
+ import {yui_tint} from "./bulma_tint.js";
112
113
 
113
114
  import "./c_yui_schema_editor.css";
114
115
 
@@ -871,7 +872,24 @@ function render(gobj)
871
872
  function open_card(gobj, logical, title, icon, tags, event, kw)
872
873
  {
873
874
  let $tags = tags.map((tag) => {
874
- return ["span", {class: `${logical}_TAG tag ${tag.style || "is-light"}`},
875
+ /*
876
+ * `is-light` ALONE IS NOT A NEUTRAL CHIP, IT IS AN UNREADABLE ONE.
877
+ *
878
+ * 'light' is one of Bulma's colours, so `is-light` is both the
879
+ * colour and the modifier -- and `.tag.is-light.is-light` matches
880
+ * an element carrying the class ONCE (a compound selector may
881
+ * match the same class twice). That rule sets the text to
882
+ * var(--bulma-light-light-invert-l), WHICH BULMA NEVER DEFINES:
883
+ * used four times in its stylesheet, declared zero. The colour
884
+ * declaration is then invalid, the background still resolves, and
885
+ * what is left is a pill with no readable label.
886
+ *
887
+ * A bare `.tag` is the neutral chip: it takes --bulma-background-l
888
+ * and --bulma-text-l, so it follows the theme and stays legible in
889
+ * both. `is-light` goes on only NEXT TO a real colour, where the
890
+ * variable it needs does exist.
891
+ */
892
+ return ["span", {class: `${logical}_TAG tag ${tag.style || ""}`.trimEnd()},
875
893
  tag.i18n
876
894
  ? [["span", {i18n: tag.i18n}, t(tag.i18n)],
877
895
  ["span", {class: "ml-1"}, `${tag.text}`]]
@@ -989,7 +1007,7 @@ function render_topics(gobj, $body)
989
1007
  ["td", {class: "SCHEMA_TOPIC_NAME"}, [
990
1008
  ["span", {class: "has-text-weight-semibold"}, `${topic.name}`],
991
1009
  topic.system_topic
992
- ? ["span", {class: "SCHEMA_TOPIC_SYSTEM tag is-light ml-2",
1010
+ ? ["span", {class: "SCHEMA_TOPIC_SYSTEM tag ml-2",
993
1011
  i18n: "system"}, t("system")]
994
1012
  : ["span", {}, ""],
995
1013
  ambiguous
@@ -1222,7 +1240,13 @@ function column_row(gobj, topic, col, index, readonly, twice)
1222
1240
  let is_pkey = (col.name === (topic.pkey || "id"));
1223
1241
 
1224
1242
  let $flags = flags.map((flag) => {
1225
- return ["span", {class: `SCHEMA_COL_FLAG tag is-light ${flag_style(flag)}`}, `${flag}`];
1243
+ /* The colour carries the meaning, and `is-light` softens it. With
1244
+ * no colour there is nothing to soften: a bare tag is the neutral
1245
+ * one, and `is-light` on its own would erase the label -- see
1246
+ * open_card(). It is why 'writable', 'image', 'wild' and every
1247
+ * other unstyled flag rendered as an empty pill. */
1248
+ return ["span", {class: `SCHEMA_COL_FLAG tag ${yui_tint(flag_style(flag))}`.trimEnd()},
1249
+ `${flag}`];
1226
1250
  });
1227
1251
 
1228
1252
  /* An unset `blob` column comes back as `{}`, which is an object and
@@ -649,9 +649,14 @@ function make_toolbar(gobj)
649
649
 
650
650
  let center_items = [
651
651
  $find_control,
652
- /* Two spans, not one string: the number is DATA and "matches" is
653
- * the word, so a language switch re-translates the half that is a
654
- * word. Spaced with CSS because createElement2 trims text nodes. */
652
+ /* ONE counted span, not a number beside a word: "1 matches" is
653
+ * what two spans always produce, because nothing there can see
654
+ * the count. `t(key, {count})` picks `matches_one` for one and
655
+ * falls back to the base key for the rest, in both languages.
656
+ * No `i18n` attribute on it: refresh_language() would call t()
657
+ * WITHOUT the count and put the plural back over the singular.
658
+ * Nothing re-translates it, so it is repainted on the next
659
+ * find, and it is hidden while the box is empty. */
655
660
  /* `display:flex` inline and NOT the `is-flex` helper: both Bulma
656
661
  * helpers carry !important, so `is-hidden is-flex` on one element
657
662
  * is decided by whichever lands later in the stylesheet — and the
@@ -661,8 +666,7 @@ function make_toolbar(gobj)
661
666
  ['div', {class: 'GRAPH_FIND_RESULT is-hidden',
662
667
  style: 'display:flex; align-items:center; gap:.3rem; ' +
663
668
  'margin-right:.5rem; font-size:.85rem;'}, [
664
- ['span', {class: 'GRAPH_FIND_COUNT'}, ''],
665
- ['span', {i18n: 'matches'}, 'matches']
669
+ ['span', {class: 'GRAPH_FIND_COUNT'}, '']
666
670
  ]]
667
671
  ];
668
672
 
@@ -2485,7 +2489,7 @@ function ac_find_result(gobj, event, kw, src)
2485
2489
  return 0;
2486
2490
  }
2487
2491
 
2488
- $count.textContent = String((kw && kw.matches) || 0);
2492
+ $count.textContent = t("matches", {count: (kw && kw.matches) || 0});
2489
2493
  $result.classList.remove("is-hidden");
2490
2494
  return 0;
2491
2495
  }
@@ -33,6 +33,7 @@ import {
33
33
  empty_string,
34
34
  log_warning,
35
35
  } from "@yuneta/gobj-js";
36
+ import {yui_tint} from "./bulma_tint.js";
36
37
 
37
38
  import {
38
39
  activate_focus_trap_on,
@@ -121,7 +122,7 @@ function show_notification(shell, kind, message, opts)
121
122
  ? {i18n: message}
122
123
  : {};
123
124
  let $note = createElement2(
124
- ["div", {class: `TOAST notification yui-notification is-${kind} is-light`,
125
+ ["div", {class: `TOAST notification yui-notification ${yui_tint(kind)}`,
125
126
  role: kind === "danger" ? "alert" : "status"},
126
127
  [
127
128
  ["button", {class: "TOAST_CLOSE delete", "aria-label": "close"}],
@@ -257,11 +257,19 @@ function build_body(shell, t)
257
257
  );
258
258
  attach_clear($search_ctrl, $search);
259
259
 
260
- /* Live match counter next to the filter. */
260
+ /* Live match counter next to the filter.
261
+ *
262
+ * ONE counted span, not a number beside a word: "1 matches" is what
263
+ * two spans always produce, because nothing there can see the
264
+ * count. `t(key, {count})` picks `matches_one` for one and falls
265
+ * back to the base key for the rest. No `i18n` attribute on it --
266
+ * refresh_language() would call t() WITHOUT the count and put the
267
+ * plural back over the singular -- so the filter, which runs on
268
+ * every keystroke, is what repaints it. */
261
269
  let $count_n = createElement2(["span", {class: "ROUTEMAP_COUNT_N"}, ""]);
262
270
  let $count = createElement2(
263
271
  ["span", {class: "ROUTEMAP_COUNT is-size-7 has-text-grey is-hidden"},
264
- [$count_n, ["span", {i18n: "matches"}, "matches"]]]
272
+ [$count_n]]
265
273
  );
266
274
  /* References are hidden by default and revealed by this toggle.
267
275
  * Remembered for the page session (not localStorage): it is a
@@ -294,7 +302,9 @@ function build_body(shell, t)
294
302
  if(q === "") {
295
303
  $count.classList.add("is-hidden");
296
304
  } else {
297
- $count_n.textContent = $tree.querySelectorAll(".ROUTEMAP_MATCH").length;
305
+ $count_n.textContent = t("matches", {
306
+ count: $tree.querySelectorAll(".ROUTEMAP_MATCH").length
307
+ });
298
308
  $count.classList.remove("is-hidden");
299
309
  }
300
310
  };