tosijs-ui 1.9.1 → 1.9.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.2
4
+
5
+ **Accessibility: every default color pair now meets WCAG AA.** Found by running
6
+ `hj map` (haltija 1.11) over the doc site, which reports a measured contrast ratio per
7
+ node — none of these were visible as wrong to the eye.
8
+
9
+ | pair | was | now |
10
+ | --- | --- | --- |
11
+ | test widget, running | white on `#fa0` — **1.9:1** | `#2b1a00` on `#fa0` — 8.8:1 |
12
+ | test widget, passed | white on `#0a0` — **3.1:1** | white on `#008a00` — 4.5:1 |
13
+ | `--tosi-accent` as text | `#EE257B` — **4.05:1** on white, 4.3:1 on the page | `#d92270` — 4.6:1 |
14
+ | `--tosi-accent` as a fill | white on `#EE257B` — **4.05:1** | white on `#d92270` — 4.8:1 |
15
+
16
+ **The brand pink is 9% darker** (`#EE257B` → `#d92270`). It is imperceptible side by side
17
+ and it was the only value that clears AA in *both* directions — as text on the page, and
18
+ as a fill carrying white text. Override `--tosi-accent` if you want the original.
19
+
20
+ Two more, found by sweeping wider — both in **example code we publish**, which matters
21
+ more than a component default because people copy it: the `.-negative { color: #e44 }` we
22
+ document for negative numbers in tables sat at **3.63:1** (now `#d32f2f`, 4.77:1), and
23
+ `#007AFF` — the accent in every theming example in our docs — was **4.02:1** under white
24
+ text (now `#0064d2`, 5.59:1).
25
+
26
+ Also fixed: the doc-site brand bar's text was a hand-tuned warm off-white
27
+ (`brandColor.rotate(30).brighten(0.9)` → `#fbeae9`) sitting at **4.09:1** on the brand —
28
+ under AA for the normal-size nav links in it. Every brighten value short of pure white
29
+ also misses, so it now uses `contrasting()`, which picks the readable extreme for whatever
30
+ the brand currently is and cannot drift when the brand moves. Three stale `#EE257B` /
31
+ `#007AFF` fallbacks were still live in `header.ts` and `doc-browser.ts` and now match.
32
+
33
+ Verified across 8 pages in both themes on haltija 1.11.2: **0 failures, 0 uncertain**.
34
+
35
+ **The browser-test lane now runs its own private haltija.** It used to reuse any running
36
+ instance and otherwise spawn with `-f`, which reclaims haltija's shared default port and
37
+ kills whatever held it. Both halves were wrong: adopting meant inheriting the *desktop
38
+ app's* window, whose visibility depends on whatever else is on screen — and `hj` rightly
39
+ refuses to drive a hidden tab, since a backgrounded tab throttles `requestAnimationFrame`
40
+ and would produce plausible-but-wrong results. The lane failed roughly two runs in three
41
+ with no programmatic way out. It now takes an isolated instance on an ephemeral port:
42
+ **4/4 runs green**, and a concurrent haltija belonging to someone else survives untouched
43
+ (verified both). Closes #18 and #21.
44
+
45
+ The test widget's text color is now **paired** with its background rather than hardcoded
46
+ to white. Amber is a genuinely light color: darkening it enough for white text turns it
47
+ brown and stops reading as "in progress", so it keeps its hue and takes dark text, which
48
+ is the conventional warning-badge treatment. New `--tosi-test-text-color-{pass,fail,running}`
49
+ variables if you theme these.
50
+
51
+ In dark mode the brand color is **brightened** rather than reused: `invertLuminance` flips
52
+ the neutrals but leaves a saturated hue roughly where it was, so a pink dark enough for a
53
+ near-white page landed at 4.4:1 on a near-black one. The hue is the brand; the luminance
54
+ belongs to whatever it sits on.
55
+
3
56
  ## 1.9.1
4
57
 
5
58
  **`tosijs-ui` did not import under Node.** Shipped `dist/` used extensionless relative
@@ -221,7 +221,7 @@ Numeric types (`number`, `currency`, `fixed`, `percent`, `sci`, `eng`, `bytes`)
221
221
  by default; `boolean` centers and renders icons. An explicit `align` — or a `dataCell` —
222
222
  always wins. Formatting follows the app locale (`setLocale()`). Numeric cells also get a
223
223
  `-negative` or `-zero` state class by value sign, so the red "Change" values above come from
224
- one CSS rule (`.-negative { color: #e44 }`), no cell renderer.
224
+ one CSS rule (`.-negative { color: #d32f2f }`), no cell renderer.
225
225
 
226
226
  ```js
227
227
  import { tosiTable } from 'tosijs-ui'
@@ -251,7 +251,7 @@ table.value = {
251
251
  preview.append(table)
252
252
  ```
253
253
  ```css
254
- .preview .-negative { color: #e44; }
254
+ .preview .-negative { color: #d32f2f; }
255
255
  .preview .-zero { opacity: 0.45; }
256
256
  ```
257
257
 
@@ -687,10 +687,10 @@ export class TosiTable extends WebComponent {
687
687
  background: varDefault.tosiTableHeaderBg(varDefault.tosiTableBg('var(--tosi-bg, #fff)')),
688
688
  },
689
689
  ':host .tr[aria-selected] .td': {
690
- background: varDefault.tosiTableSelectedBg('var(--tosi-accent, #007AFF22)'),
690
+ background: varDefault.tosiTableSelectedBg('var(--tosi-accent, #0064d222)'),
691
691
  },
692
692
  ':host .td:focus, :host .th:focus': {
693
- outline: '2px solid var(--tosi-accent, #007AFF)',
693
+ outline: '2px solid var(--tosi-accent, #0064d2)',
694
694
  outlineOffset: '-2px',
695
695
  zIndex: '1',
696
696
  },
@@ -164,12 +164,29 @@ import { popMenu } from './menu.js';
164
164
  import { codeEditor } from './code-editor.js';
165
165
  import { tosiDiff } from './diff.js';
166
166
  const { div, span, a, header, button, template, input, h2, img, details, summary, ul, li, } = elements;
167
- // Test colors
167
+ /*
168
+ Test colors, and the text color that goes ON each — because contrast is a PAIR.
169
+
170
+ The widget used one hardcoded `color: white` and swapped only the background, so the
171
+ amber running state rendered white-on-#fa0 at **1.9:1** and the green pass state at
172
+ **3.1:1**, both well under WCAG AA's 4.5:1. (Found by `hj map`, which reports a
173
+ `contrast` ratio per node — invisible to the eye until measured.)
174
+
175
+ Amber is a genuinely light color: darkening it far enough for white text turns it brown
176
+ (63% brightness) and stops reading as "in progress". So it keeps its hue and takes dark
177
+ text at 11:1, which is the conventional warning-badge treatment. Green only needed a
178
+ nudge (81% brightness) to carry white.
179
+ */
168
180
  const testColor = {
169
- pass: varDefault.testColorPass('#0a0'),
181
+ pass: varDefault.testColorPass('#008a00'),
170
182
  fail: varDefault.testColorFail('#c00'),
171
183
  running: varDefault.testColorRunning('#fa0'),
172
184
  };
185
+ const testTextColor = {
186
+ pass: varDefault.testTextColorPass('#fff'),
187
+ fail: varDefault.testTextColorFail('#fff'),
188
+ running: varDefault.testTextColorRunning('#2b1a00'),
189
+ };
173
190
  // Test indicator styles - widget inherits button styles from base stylesheet
174
191
  const testIndicatorStyleSpec = {
175
192
  '.view-source': { opacity: '0.9' },
@@ -215,25 +232,30 @@ const testIndicatorStyleSpec = {
215
232
  // was overlaying this.
216
233
  '.test-widget': {
217
234
  _testBg: testColor.running,
235
+ _testFg: testTextColor.running,
218
236
  position: 'fixed',
219
237
  bottom: vars.spacing,
220
238
  left: vars.spacing,
221
239
  zIndex: '1000',
222
240
  background: vars.testBg,
223
- color: 'white',
241
+ // Paired with the background, not fixed to white — see testTextColor above.
242
+ color: vars.testFg,
224
243
  gap: vars.spacing50,
225
244
  },
226
245
  '.test-widget[hidden]': { display: 'none' },
227
246
  '.test-widget.-running': {
228
247
  _testBg: testColor.running,
248
+ _testFg: testTextColor.running,
229
249
  animation: 'test-appear 0.3s ease-out, test-pulse 2s ease-in-out 0.3s infinite',
230
250
  },
231
251
  '.test-widget.-passed': {
232
252
  _testBg: testColor.pass,
253
+ _testFg: testTextColor.pass,
233
254
  animation: 'test-fade 3s ease-out forwards',
234
255
  },
235
256
  '.test-widget.-failed': {
236
257
  _testBg: testColor.fail,
258
+ _testFg: testTextColor.fail,
237
259
  animation: 'test-pulse 2s ease-in-out infinite',
238
260
  },
239
261
  // Count badge
@@ -250,7 +272,7 @@ const testIndicatorStyleSpec = {
250
272
  },
251
273
  // Deep-linked example (e.g. arriving at /slug/#example-2): brief highlight pulse
252
274
  '@keyframes example-target-pulse': {
253
- from: { boxShadow: `0 0 0 3px ${varDefault.accent('#007aff')}` },
275
+ from: { boxShadow: `0 0 0 3px ${varDefault.accent('#0064d2')}` },
254
276
  to: { boxShadow: '0 0 0 3px transparent' },
255
277
  },
256
278
  '.example-target': {
@@ -20,7 +20,7 @@ export const SIDEBAR_WIDTH = 200;
20
20
  export const SIDEBAR_BREAKPOINT = 600;
21
21
  /** Compute the full set of `:root` color variables from a few base colors. */
22
22
  export function docSystemColors(theme = {}) {
23
- const brandColor = Color.fromCss(theme.accent ?? '#EE257B');
23
+ const brandColor = Color.fromCss(theme.accent ?? '#d92270');
24
24
  return {
25
25
  _textColor: theme.text ?? '#222',
26
26
  _brandColor: brandColor,
@@ -32,7 +32,18 @@ export function docSystemColors(theme = {}) {
32
32
  _barColor: brandColor.opacity(0.4),
33
33
  _focusColor: brandColor.opacity(0.7),
34
34
  _placeholderColor: brandColor.opacity(0.4),
35
- _brandTextColor: brandColor.rotate(30).brighten(0.9),
35
+ /*
36
+ `contrasting()` rather than a hand-tuned tint.
37
+
38
+ `brandColor.rotate(30).brighten(0.9)` produced a warm off-white (#fbeae9) that sat at
39
+ **4.09:1** on the brand bar — under AA for the normal-size nav links in it. Every
40
+ brighten value short of pure white also misses (0.96 lands at 4.49), so the tint had
41
+ to go; it was imperceptible anyway. `contrasting()` picks the readable extreme for
42
+ whatever the brand currently is, so this cannot drift the next time the brand moves.
43
+ Dark mode is unaffected: invertLuminance flips this to near-black on the brightened
44
+ bar, which is already high-contrast.
45
+ */
46
+ _brandTextColor: brandColor.contrasting(),
36
47
  _insetBg: brandColor.rotate(45).brighten(0.8),
37
48
  // Neutral off-white (not brand-tinted); dark mode inverts its luminance to
38
49
  // off-black, so the editor surface stays a clean neutral in both themes.
@@ -55,7 +66,7 @@ export function docSystemColors(theme = {}) {
55
66
  /** Build the full doc-system stylesheet for a given base theme. */
56
67
  export function docSystemStyleSpec(theme = {}) {
57
68
  const colors = docSystemColors(theme);
58
- const brandColor = Color.fromCss(theme.accent ?? '#EE257B');
69
+ const brandColor = Color.fromCss(theme.accent ?? '#d92270');
59
70
  return {
60
71
  '@import': 'https://fonts.googleapis.com/css2?family=Aleo:ital,wght@0,100..900;1,100..900&famiSpline+Sans+Mono:ital,wght@0,300..700;1,300..700&display=swap',
61
72
  ':root': {
@@ -89,6 +100,16 @@ export function docSystemStyleSpec(theme = {}) {
89
100
  },
90
101
  '.darkmode': {
91
102
  ...invertLuminance(colors),
103
+ /*
104
+ Lift the brand color for dark backgrounds.
105
+
106
+ `invertLuminance` flips the neutrals but leaves a saturated brand hue roughly where
107
+ it was, so one accent has to serve both a near-white and a near-black page — and a
108
+ pink dark enough to clear AA on `#fafafa` (4.56:1) lands at 4.4:1 on `#050505`,
109
+ just under. Brightening it in dark mode is the standard resolution: the hue is the
110
+ brand, the luminance belongs to the background it sits on.
111
+ */
112
+ _brandColor: brandColor.brighten(0.18),
92
113
  _shadowColor: brandColor.opacity(0.5),
93
114
  _menuShadow: `0 0 0 2px ${brandColor.opacity(0.75)}`,
94
115
  _menuSeparatorColor: brandColor.opacity(0.5),
@@ -9,6 +9,7 @@ Build-time only (Bun APIs). Never import this from browser code.
9
9
  */
10
10
  import * as path from 'path';
11
11
  import { statSync } from 'fs';
12
+ import { tmpdir } from 'os';
12
13
  import { $, spawn } from 'bun';
13
14
  import { buildSite } from './orchestrator.js';
14
15
  import { preflight } from './preflight.js';
@@ -1210,7 +1211,6 @@ export async function devServer(config, opts = {}) {
1210
1211
  testReportResolve = resolve;
1211
1212
  setTimeout(() => reject(new Error('Browser tests timed out')), testTimeout);
1212
1213
  });
1213
- let haltija;
1214
1214
  /**
1215
1215
  * Tear down the haltija WE started — including the Electron grandchild. Defined up
1216
1216
  * front so EVERY failure path can call it, including the start-timeout below, which
@@ -1270,72 +1270,55 @@ export async function devServer(config, opts = {}) {
1270
1270
 
1271
1271
  Declining to adopt is safe: we spawn with `-f`, which stomps the stale instance.
1272
1272
  */
1273
- const haltijaReachable = async () => {
1273
+ /*
1274
+ ALWAYS an isolated instance. Never adopt, never stomp.
1275
+
1276
+ The lane used to reuse any reachable haltija and otherwise spawn with `-f`, which
1277
+ reclaims the shared default port and SIGTERMs whatever held it — often another
1278
+ project's session. Both halves were a problem:
1279
+
1280
+ - adopting meant inheriting the DESKTOP app's window, whose visibility depends on
1281
+ whatever else is on screen. `hj` refuses to drive a hidden tab (rightly — a
1282
+ backgrounded tab throttles rAF, so results are plausible-but-wrong), so the lane
1283
+ failed intermittently, roughly two runs in three, with no programmatic way out.
1284
+ - `-f` made this project's test run destructive to everyone else's.
1285
+
1286
+ `--private` gives us our own server on an EPHEMERAL port, so there is nothing to
1287
+ contend for and nothing to kill. The port is written to a file rather than guessed.
1288
+ (tosijs-ui#18, #21.)
1289
+ */
1290
+ const portFile = `${tmpdir()}/tosijs-haltija-${process.pid}.json`;
1291
+ console.log('Starting a private haltija…');
1292
+ const haltija = spawn(['bunx', HALTIJA_PKG, '--private', '--port-file', portFile], {
1293
+ // stdout/stderr are NOT inherited: `bunx haltija` launches Electron as a
1294
+ // grandchild, and killing the bunx wrapper does not kill it. An orphan holding
1295
+ // our inherited stdout keeps the pipe open forever, so the command looks hung
1296
+ // long after it exited.
1297
+ stdout: 'ignore',
1298
+ stderr: 'ignore',
1299
+ });
1300
+ // Read the port it actually bound, rather than assuming one.
1301
+ let hjPort = '';
1302
+ for (let i = 0; i < 60; i++) {
1303
+ await new Promise((resolve) => setTimeout(resolve, 500));
1274
1304
  try {
1275
- /*
1276
- `--json`, and fail CLOSED.
1277
-
1278
- Bare `hj windows` appends a dim hint line to STDOUT after the JSON, so
1279
- `JSON.parse` throws and a `catch` that fell through to `return true` adopted the
1280
- very dead server this check exists to reject — the gate was inert on every
1281
- npm-installed CLI. It passed verification here only because this machine's `hj` is
1282
- a standalone bundle with no `hints.json` beside it, so the hint was never emitted:
1283
- a real behavioural test, run against an environment adopters do not have.
1284
-
1285
- Failing closed costs nothing: the spawn below uses `-f`, which stomps a stale
1286
- instance. Adopting a server we could not read is the expensive direction.
1287
- */
1288
- const out = await $ `hj windows --json`.quiet().text();
1289
- return haltijaIsDrivable(out);
1305
+ const info = JSON.parse(await Bun.file(portFile).text());
1306
+ hjPort = String(info.port ?? info.address?.port ?? '');
1307
+ if (hjPort)
1308
+ break;
1290
1309
  }
1291
1310
  catch {
1292
- return false;
1311
+ /* not written yet */
1293
1312
  }
1294
- };
1295
- const adoptable = await haltijaReachable();
1296
- if (!adoptable) {
1297
- /*
1298
- Leave a receipt: spawning with `-f` reclaims haltija's shared default port, which
1299
- SIGTERMs whatever was there — frequently another project's session, or the
1300
- developer's own. This file already sets that norm for killStrayServer; declining to
1301
- adopt makes the stomp MORE frequent, so it must be at least as loud.
1302
- */
1303
- console.log(`No drivable haltija (server absent, or up with no connected tab).\n` +
1304
- ` Starting one with -f, which reclaims the shared haltija port — any other\n` +
1305
- ` project's session on it will be stopped. (cwd: ${process.cwd()})`);
1306
1313
  }
1307
- if (adoptable) {
1308
- console.log('Using existing haltija browser');
1309
- }
1310
- else {
1311
- console.log('Starting haltija...');
1312
- // stdout/stderr are NOT inherited: `bunx haltija` launches an Electron app as a
1313
- // grandchild, and killing the bunx wrapper does not kill it. An orphaned Electron
1314
- // holding our inherited stdout keeps the pipe open forever, so a CI job or an
1315
- // agent capturing this command's output sees it hang long after it exited (0).
1316
- haltija = spawn(['bunx', HALTIJA_PKG, '-f'], {
1317
- stdout: 'ignore',
1318
- stderr: 'ignore',
1319
- });
1320
- const waitStarted = Date.now();
1321
- console.log('Waiting for haltija…');
1322
- let up = false;
1323
- for (let i = 0; i < 20; i++) {
1324
- await new Promise((resolve) => setTimeout(resolve, 500));
1325
- if (await haltijaReachable()) {
1326
- up = true;
1327
- break;
1328
- }
1329
- }
1330
- if (!up) {
1331
- console.error(`haltija did not become drivable within ${Math.round((Date.now() - waitStarted) / 1000)}s.\n` +
1332
- ` A single \`hj windows\` can block for several seconds when a server is up\n` +
1333
- ` with no tab, so this can take much longer than the poll count suggests.`);
1334
- await stopHaltija();
1335
- server.stop();
1336
- process.exit(1);
1337
- }
1314
+ if (!hjPort) {
1315
+ console.error(`haltija did not report a private port within 30s (${portFile}).\n` +
1316
+ ` Is \`bunx ${HALTIJA_PKG}\` able to run? Try it by hand.`);
1317
+ await stopHaltija();
1318
+ server.stop();
1319
+ process.exit(1);
1338
1320
  }
1321
+ console.log(` private haltija on port ${hjPort}`);
1339
1322
  console.log('Opening demo site...');
1340
1323
  // haltija 1.4: `hj navigate` exits NON-ZERO on failure (no browser reachable, a
1341
1324
  // window that didn't take the URL, …). Before 1.4 it exited 0 regardless, so a
@@ -1343,7 +1326,9 @@ export async function devServer(config, opts = {}) {
1343
1326
  // timeout with a misleading "Browser tests timed out". Now we can read the exit
1344
1327
  // code: fail immediately, say why, and tear down the haltija we spawned so the
1345
1328
  // next run isn't inheriting a half-navigated browser.
1346
- const nav = await $ `hj navigate https://localhost:${PORT}`.nothrow().quiet();
1329
+ const nav = await $ `hj --port ${hjPort} navigate https://localhost:${PORT}`
1330
+ .nothrow()
1331
+ .quiet();
1347
1332
  if (nav.exitCode !== 0) {
1348
1333
  console.error(`hj navigate failed (exit ${nav.exitCode}): ${nav.stderr.toString().trim() || 'no browser reachable'}`);
1349
1334
  await stopHaltija();
package/dist/header.js CHANGED
@@ -101,7 +101,7 @@ export class TosiHeader extends Component {
101
101
  display: 'flex',
102
102
  alignItems: 'center',
103
103
  padding: varDefault.tosiHeaderPadding('12px 24px'),
104
- background: varDefault.tosiHeaderBg('var(--tosi-accent, #EE257B)'),
104
+ background: varDefault.tosiHeaderBg('var(--tosi-accent, #d92270)'),
105
105
  lineHeight: varDefault.tosiHeaderLineHeight('32px'),
106
106
  gap: varDefault.tosiHeaderGap('8px'),
107
107
  },