tosijs-ui 1.7.2 → 1.7.3

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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.3
4
+
5
+ - **New `<tosi-pocket-bar>` component.** A "pocket buttonbar": a single translucent icon at
6
+ rest that expands into a bar of slotted icon-button controls on hover/tap, then collapses
7
+ again. `direction` (the full `FloatPosition` vocabulary, `auto` by default) determines both
8
+ layout and growth direction; the handle stays put and toggles the bar; touch counts as hover.
9
+ Built as a thin wrapper around `positionFloat`.
10
+ - **The live-example toolbar is now a pocket bar**, pinned to the top-right of the preview (so
11
+ it never sits over the code editors in side-by-side mode). The collapsed handle carries the
12
+ example's pass/fail color, "run tests" is a checkbox that greys when off, and the bar stays
13
+ visible when the example is maximized.
14
+ - **Build system is fragility-tolerant.** An *unsupported import* in a live example (a bare
15
+ specifier the runtime can't resolve) now downgrades that block to display-only with a warning
16
+ instead of failing the whole build; genuine syntax errors still fail the build.
17
+ - **Removed a render-skip anti-pattern** from `<tosi-segmented>`, `<tosi-color>`, and
18
+ `<tosi-form>`. They previously depended on `render()` being *skipped* after interaction via a
19
+ `valueChanged` flag — fragile timing that broke under tosijs's parts changes. Renders are now
20
+ idempotent: they skip only *provably-redundant* writes by comparing values (colors compared
21
+ **parsed**, so a `#rrggbb ↔ rgba()` round-trip no longer clobbers the caret/selection in the
22
+ field being edited).
23
+ - **Code editor background is now a neutral off-white** (off-black in dark mode) instead of a
24
+ brand-tinted color — both the default `<tosi-code>` and live examples.
25
+ - **Fix: nav flickered on refresh / reflowed on hydration.** The static no-JS nav relied on the
26
+ browser's native `<details>` collapse (`::details-content { content-visibility: hidden }`),
27
+ which Chromium applies lazily and non-deterministically on first paint — a closed section
28
+ could render at full height for a frame before snapping shut. Closed sections now collapse
29
+ synchronously via `display: none` (still opening on a no-JS `<summary>` toggle). Enforced by
30
+ `tests/hydration.pw.ts`.
31
+ - **Moved the doc-browser test-counter widget to the bottom-left** so it no longer sits under
32
+ the haltija dev overlay.
33
+ - **tosijs bumped to `^1.7.8`**, which fixes the `this.parts` proxy crossing into nested
34
+ component instances (tosijs#20 — surfaced by the self-hosting doc demo, where clicking "edit"
35
+ on one example opened the editor in the wrong one) and change-handler value staleness
36
+ (tosijs#21).
37
+
3
38
  ## 1.7.2
4
39
 
5
40
  - **Fix: doc sites are now mount-point-agnostic** (issue #25). The build baked `basePath` into
@@ -33,7 +33,6 @@ declare class ColorInput extends Component<ColorParts> {
33
33
  };
34
34
  };
35
35
  content: HTMLInputElement[];
36
- private valueChanged;
37
36
  update: (event: Event) => void;
38
37
  connectedCallback(): void;
39
38
  render(): void;
@@ -71,7 +71,6 @@ class ColorInput extends Component {
71
71
  }),
72
72
  input({ title: 'css color spec', part: 'css' }),
73
73
  ];
74
- valueChanged = false;
75
74
  update = (event) => {
76
75
  const { rgb, alpha, css } = this.parts;
77
76
  if (event.type === 'input') {
@@ -86,7 +85,6 @@ class ColorInput extends Component {
86
85
  }
87
86
  rgb.style.opacity = String(this.color.a);
88
87
  this.value = this.color.rgba;
89
- this.valueChanged = true;
90
88
  };
91
89
  connectedCallback() {
92
90
  super.connectedCallback();
@@ -96,16 +94,32 @@ class ColorInput extends Component {
96
94
  css.addEventListener('change', this.update);
97
95
  }
98
96
  render() {
99
- if (this.valueChanged) {
100
- this.valueChanged = false;
101
- return;
102
- }
103
97
  const { rgb, alpha, css } = this.parts;
104
98
  this.color = Color.fromCss(this.value);
105
- rgb.value = this.color.html.substring(0, 7);
106
- rgb.style.opacity = String(this.color.a);
107
- alpha.value = String(this.color.a);
108
- css.value = this.color.html;
99
+ const rgba = this.color.rgba;
100
+ const alphaStr = String(this.color.a);
101
+ // Idempotent: only WRITE an input whose value doesn't already represent this
102
+ // colour. Compare the PARSED colour, not the string — `#rrggbb` and `rgba(...)`
103
+ // are the same colour, so a string compare (or a blind write) would clobber the
104
+ // caret / selection and re-round-trip the user's format on the field they're
105
+ // editing, every render. Skipping provably-redundant work is right; a `valueChanged`
106
+ // timing flag to decide it was the fragile part (broke under tosijs 1.7.7).
107
+ const sameColor = (v) => {
108
+ try {
109
+ return Color.fromCss(v).rgba === rgba;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ };
115
+ if (!sameColor(rgb.value))
116
+ rgb.value = this.color.html.substring(0, 7);
117
+ if (rgb.style.opacity !== alphaStr)
118
+ rgb.style.opacity = alphaStr;
119
+ if (alpha.value !== alphaStr)
120
+ alpha.value = alphaStr;
121
+ if (!sameColor(css.value))
122
+ css.value = this.color.html;
109
123
  }
110
124
  }
111
125
  export const colorInput = ColorInput.elementCreator();
@@ -206,12 +206,14 @@ const testIndicatorStyleSpec = {
206
206
  background: testColor.fail,
207
207
  animation: 'test-pulse 2s ease-in-out infinite',
208
208
  },
209
- // Floating widget - position and colors only, inherits button structure
209
+ // Floating widget - position and colors only, inherits button structure.
210
+ // Bottom-LEFT: bottom-right is where the injected haltija dev widget sits, and it
211
+ // was overlaying this.
210
212
  '.test-widget': {
211
213
  _testBg: testColor.running,
212
214
  position: 'fixed',
213
215
  bottom: vars.spacing,
214
- right: vars.spacing,
216
+ left: vars.spacing,
215
217
  zIndex: '1000',
216
218
  background: vars.testBg,
217
219
  color: 'white',
@@ -8,6 +8,8 @@ export interface DocSystemTheme {
8
8
  text?: string;
9
9
  buttonBg?: string;
10
10
  inputBg?: string;
11
+ /** code-editor surface (`--code-bg`); neutral off-white by default, not brand-tinted. */
12
+ codeBg?: string;
11
13
  }
12
14
  /** Compute the full set of `:root` color variables from a few base colors. */
13
15
  export declare function docSystemColors(theme?: DocSystemTheme): XinStyleRule;
@@ -34,7 +34,9 @@ export function docSystemColors(theme = {}) {
34
34
  _placeholderColor: brandColor.opacity(0.4),
35
35
  _brandTextColor: brandColor.rotate(30).brighten(0.9),
36
36
  _insetBg: brandColor.rotate(45).brighten(0.8),
37
- _codeBg: brandColor.rotate(-15).desaturate(0.5).brighten(0.9),
37
+ // Neutral off-white (not brand-tinted); dark mode inverts its luminance to
38
+ // off-black, so the editor surface stays a clean neutral in both themes.
39
+ _codeBg: theme.codeBg ?? '#fdfdfd',
38
40
  _linkColor: brandColor.rotate(-30).darken(0.5),
39
41
  _shadowColor: '#0004',
40
42
  _menuBg: '#fafafa',
@@ -476,6 +478,18 @@ export function docSystemStyleSpec(theme = {}) {
476
478
  '.doc-nav details > ul': {
477
479
  paddingLeft: vars.spacing,
478
480
  },
481
+ // Collapse a closed section's list SYNCHRONOUSLY. The UA collapses <details>
482
+ // via `::details-content { content-visibility: hidden }`, which is applied
483
+ // lazily and — for this static tree — non-deterministically on first paint:
484
+ // sometimes the closed section is still laid out at full height for a frame
485
+ // before it snaps shut. That frame is a visible flicker on refresh, and it made
486
+ // the "nav does not move on hydration" test measure a 36px row that hydration
487
+ // then collapses to 0. An explicit display:none collapses it in the first layout
488
+ // pass with no content-visibility race, and still opens on a no-JS <summary>
489
+ // toggle (the UA adds [open], so this rule stops matching).
490
+ '.doc-nav details:not([open]) > ul': {
491
+ display: 'none',
492
+ },
479
493
  // <summary> is the section header (its triangle toggles; the link navigates).
480
494
  '.doc-nav summary': {
481
495
  cursor: 'pointer',
@@ -20,8 +20,9 @@ the bundle step, the ePub step, and generate-css; see the Bun.build note in
20
20
  orchestrator.ts.
21
21
 
22
22
  bun check-examples-cli.ts <docs.json> [contextKey ...]
23
- → writes ExampleProblem[] as JSON to stdout; exit 0 whether or not problems were
24
- found (a problem is data, not a crash — only a real failure exits non-zero).
23
+ → writes { problems, warnings, bakes } as JSON to stdout; exit 0 whether or not
24
+ problems were found (a problem is data, not a crash — only a real failure exits
25
+ non-zero). The parent fails the build on `problems`, warns on `warnings`.
25
26
 
26
27
  Build-time only. Never import this from browser code.
27
28
  */
@@ -36,7 +37,7 @@ const contextKeys = process.argv.slice(3);
36
37
  // non-context imports validate as dynamic `<prefix><spec>` imports rather than failing.
37
38
  const importPrefix = process.env.TOSI_IMPORT_PREFIX || undefined;
38
39
  const corpus = JSON.parse(await Bun.file(docsJson).text());
39
- const { problems, bakes } = await checkExamples(corpus, {
40
+ const { problems, warnings, bakes } = await checkExamples(corpus, {
40
41
  ...(contextKeys.length ? { contextKeys } : {}),
41
42
  ...(importPrefix ? { importPrefix } : {}),
42
43
  });
@@ -45,6 +46,7 @@ const { problems, bakes } = await checkExamples(corpus, {
45
46
  // Maps can't JSON-roundtrip, so bakes go over as [filename, [source, {dialect,js}][]][].
46
47
  process.stdout.write(JSON.stringify({
47
48
  problems,
49
+ warnings,
48
50
  bakes: Array.from(bakes.entries()).map(([filename, docBakes]) => [
49
51
  filename,
50
52
  Array.from(docBakes.entries()),
@@ -8,7 +8,20 @@ export interface ExampleProblem {
8
8
  snippet: string;
9
9
  }
10
10
  export interface ExampleCheck {
11
+ /**
12
+ * Blocks that failed to build for a real reason (a tjs/TS syntax error, a
13
+ * transpile failure). These FAIL the build — broken code shouldn't ship.
14
+ */
11
15
  problems: ExampleProblem[];
16
+ /**
17
+ * Blocks that reference a package the environment can't provide (a non-context
18
+ * import, no import-resolver). The code isn't broken, it just can't run here —
19
+ * almost always illustrative code that should be tagged `typescript` (display-
20
+ * only) rather than `ts`. These WARN and are treated as display-only; they do
21
+ * NOT fail the build. (Enable `importResolver`, or tag the block display-only,
22
+ * to silence the warning.)
23
+ */
24
+ warnings: ExampleProblem[];
12
25
  /**
13
26
  * Build-time transpiled JS for `tjs` blocks, grouped by doc filename, each keyed
14
27
  * by exact source text. The renderer embeds a doc's bakes as hidden scripts (so
@@ -4,17 +4,25 @@ Build-time transpile check for live examples.
4
4
  Every executable code block in the corpus (`js` / `tjs` / `ts` / `test`) is put
5
5
  through the SAME front half of the runtime pipeline the live-example component
6
6
  uses — `rewriteImports` → the tjs-lang transform → `new AsyncFunction(...)` — so a
7
- block that can't build (a tjs/TS syntax error, an unsupported import, or
8
- illustrative config mistakenly tagged with an executable language like `ts`
9
- instead of the display-only `typescript`) fails the build *at authoring time*,
10
- on every page, instead of silently rendering an error only when someone opens
11
- that page. `html` / `css` and display-only languages (`typescript`, `json`, …)
12
- are not executed, so they're skipped.
7
+ block that can't build is caught *at authoring time*, on every page, instead of
8
+ silently rendering an error only when someone opens that page. The two outcomes
9
+ are graded differently (this is the point):
10
+
11
+ - a real **syntax / transpile error** is broken code → a `problem` that FAILS
12
+ the build;
13
+ - an **unsupported import** (a non-context package, no import-resolver) is fine
14
+ code the doc environment just can't run → a `warning`: the block is treated as
15
+ display-only and the build survives. It's almost always illustrative code that
16
+ should have been tagged `typescript` instead of `ts` — a mistagging shouldn't
17
+ take the whole build down with it.
18
+
19
+ `html` / `css` and display-only languages (`typescript`, `json`, …) are not
20
+ executed, so they're skipped.
13
21
 
14
22
  Build-time only (bun). Never import from browser code.
15
23
  */
16
24
  import { marked } from 'marked';
17
- import { rewriteImports, AsyncFunction, loadTransform, } from '../../live-example/code-transform';
25
+ import { rewriteImports, AsyncFunction, loadTransform, UnsupportedImportError, } from '../../live-example/code-transform';
18
26
  // The default live-example context (matches the IIFE globals the pages provide).
19
27
  // A project that sets a custom `context` on its <tosi-doc-system> can pass its
20
28
  // own keys; these are the tosijs-ui defaults.
@@ -58,6 +66,7 @@ function collectCodeTokens(text) {
58
66
  export async function checkExamples(docs, opts = {}) {
59
67
  const contextKeys = opts.contextKeys ?? DEFAULT_CONTEXT_KEYS;
60
68
  const problems = [];
69
+ const warnings = [];
61
70
  const bakes = new Map();
62
71
  for (const doc of docs) {
63
72
  for (const block of collectCodeTokens(doc.text)) {
@@ -97,17 +106,24 @@ export async function checkExamples(docs, opts = {}) {
97
106
  }
98
107
  }
99
108
  catch (err) {
100
- problems.push({
109
+ const entry = {
101
110
  filename: doc.filename,
102
111
  title: doc.title,
103
112
  lang: block.lang,
104
113
  error: err.message || String(err),
105
114
  snippet: block.text.split('\n').slice(0, 3).join('\n'),
106
- });
115
+ };
116
+ // An unsupported import means the block references deps the environment
117
+ // can't provide — not broken code. Warn (display-only), don't fail the
118
+ // build. A real syntax/transpile error is a genuine problem.
119
+ if (err instanceof UnsupportedImportError)
120
+ warnings.push(entry);
121
+ else
122
+ problems.push(entry);
107
123
  }
108
124
  }
109
125
  }
110
- return { problems, bakes };
126
+ return { problems, warnings, bakes };
111
127
  }
112
128
  /** Format problems for a build log. */
113
129
  export function formatExampleProblems(problems) {
@@ -132,6 +132,7 @@ async function checkExamplesInChild(docsJson, importPrefix) {
132
132
  const payload = JSON.parse(out);
133
133
  return {
134
134
  problems: payload.problems,
135
+ warnings: payload.warnings ?? [],
135
136
  bakes: new Map(payload.bakes.map(([file, e]) => [file, new Map(e)])),
136
137
  };
137
138
  }
@@ -239,14 +240,26 @@ export async function buildSite(config) {
239
240
  : undefined;
240
241
  let exampleBakes;
241
242
  if (config.checkExamples !== false) {
242
- const { problems, bakes } = await checkExamplesInChild(DOCS_JSON, resolverPrefix);
243
+ const { problems, warnings, bakes } = await checkExamplesInChild(DOCS_JSON, resolverPrefix);
243
244
  exampleBakes = bakes;
245
+ // Unsupported imports don't fail the build — the code isn't broken, it just
246
+ // can't run in the doc environment (almost always illustrative code that
247
+ // should be tagged `typescript`). Warn loudly, treat the block as display-only,
248
+ // and keep going. Only real syntax/transpile errors below are fatal.
249
+ if (warnings.length) {
250
+ console.warn(`⚠️ doc-site build: ${warnings.length} live example block(s) can't run ` +
251
+ `here and were left as display-only:\n\n` +
252
+ formatExampleProblems(warnings) +
253
+ `\n\nTag each with a display-only language like \`typescript\` (instead of` +
254
+ ` \`js\`/\`ts\`) to silence this, or enable \`importResolver\` to import` +
255
+ ` other packages.\n`);
256
+ }
244
257
  if (problems.length) {
245
258
  throw new Error(`doc-site build: ${problems.length} live example(s) failed to build:\n\n` +
246
259
  formatExampleProblems(problems) +
247
260
  `\n\nFix the code, or — if a block is illustrative and not meant to run —` +
248
261
  ` tag it with a display-only language like \`typescript\` instead of` +
249
- ` \`js\`/\`ts\`. (Disable with checkExamples: false.)`);
262
+ ` \`js\`/\`ts\`. (Disable all checking with checkExamples: false.)`);
250
263
  }
251
264
  }
252
265
  // Copy static-asset dirs into the web root.
package/dist/form.d.ts CHANGED
@@ -45,7 +45,6 @@ export declare class TosiField extends XinComponent {
45
45
  };
46
46
  value: any;
47
47
  content: HTMLLabelElement;
48
- private valueChanged;
49
48
  handleChange: () => void;
50
49
  connectedCallback(): void;
51
50
  render(): void;
package/dist/form.js CHANGED
@@ -430,6 +430,10 @@ function getInputValue(input) {
430
430
  : input.value;
431
431
  }
432
432
  }
433
+ // Idempotent: write only when the value actually differs. Blindly re-assigning
434
+ // `input.value` (even the same string) moves the caret / drops the selection of a
435
+ // field being edited, so render must not do it as redundant work — and it lets
436
+ // render run any number of times without side-effects.
433
437
  function setElementValue(input, value) {
434
438
  if (!(input instanceof HTMLElement)) {
435
439
  // do nothing
@@ -437,20 +441,26 @@ function setElementValue(input, value) {
437
441
  else if (input instanceof HTMLInputElement) {
438
442
  switch (input.type) {
439
443
  case 'checkbox':
440
- input.checked = value;
444
+ if (input.checked !== Boolean(value))
445
+ input.checked = value;
441
446
  break;
442
- case 'radio':
443
- input.checked = value === input.value;
447
+ case 'radio': {
448
+ const checked = value === input.value;
449
+ if (input.checked !== checked)
450
+ input.checked = checked;
444
451
  break;
445
- default:
446
- input.value = String(value || '');
452
+ }
453
+ default: {
454
+ const v = String(value || '');
455
+ if (input.value !== v)
456
+ input.value = v;
457
+ }
447
458
  }
448
459
  }
449
- else {
450
- if (value != null || input.value != null) {
451
- ;
452
- input.value = String(value || '');
453
- }
460
+ else if (value != null || input.value != null) {
461
+ const v = String(value || '');
462
+ if (input.value !== v)
463
+ input.value = v;
454
464
  }
455
465
  }
456
466
  export class TosiField extends XinComponent {
@@ -499,7 +509,6 @@ export class TosiField extends XinComponent {
499
509
  };
500
510
  value = null;
501
511
  content = label(tosiSlot({ part: 'caption' }), span({ part: 'field' }, tosiSlot({ part: 'input', name: 'input' }), input({ part: 'valueHolder' })));
502
- valueChanged = false;
503
512
  handleChange = () => {
504
513
  const { input, valueHolder } = this.parts;
505
514
  const inputElement = (input.children[0] || valueHolder);
@@ -507,7 +516,6 @@ export class TosiField extends XinComponent {
507
516
  valueHolder.value = inputElement.value;
508
517
  }
509
518
  this.value = getInputValue(inputElement);
510
- this.valueChanged = true;
511
519
  const form = this.closest('tosi-form');
512
520
  if (form && this.key !== '') {
513
521
  switch (this.type) {
@@ -537,25 +545,31 @@ export class TosiField extends XinComponent {
537
545
  input.addEventListener('change', this.handleChange, true);
538
546
  }
539
547
  render() {
540
- if (this.valueChanged) {
541
- this.valueChanged = false;
542
- return;
543
- }
544
548
  const { input, caption, valueHolder, field } = this.parts;
545
549
  if (caption.textContent?.trim() === '') {
546
550
  caption.append(this.caption !== '' ? this.caption : this.key);
547
551
  }
552
+ // Build the type-specific child ONCE and reuse it. Re-creating the <textarea> /
553
+ // <tosi-color> every render (the old behavior, only avoided by a `valueChanged`
554
+ // timing flag) destroyed focus, caret and selection mid-edit — provably redundant
555
+ // when the child of the right kind already exists. The value is reconciled
556
+ // idempotently by setElementValue below.
548
557
  if (this.type === 'text') {
549
- input.textContent = '';
550
- const textarea = elements.textarea({ value: this.value });
558
+ let textarea = input.children[0];
559
+ if (!(textarea instanceof HTMLTextAreaElement)) {
560
+ input.textContent = '';
561
+ textarea = elements.textarea({ value: this.value });
562
+ input.append(textarea);
563
+ }
551
564
  if (this.placeholder) {
552
565
  textarea.setAttribute('placeholder', this.placeholder);
553
566
  }
554
- input.append(textarea);
555
567
  }
556
568
  else if (this.type === 'color') {
557
- input.textContent = '';
558
- input.append(colorInput({ value: this.value }));
569
+ if (!input.children[0]?.matches('tosi-color')) {
570
+ input.textContent = '';
571
+ input.append(colorInput({ value: this.value }));
572
+ }
559
573
  }
560
574
  else if (input.children.length === 0) {
561
575
  attr(valueHolder, 'placeholder', this.placeholder);