tosijs-ui 1.6.7 → 1.6.9

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.
@@ -4,6 +4,12 @@ export declare class CodeEditor extends WebComponent {
4
4
  private source;
5
5
  get value(): string;
6
6
  set value(text: string);
7
+ private _original;
8
+ get original(): string;
9
+ set original(text: string);
10
+ private diffOverlay;
11
+ get showingDiff(): boolean;
12
+ showDiff(on: boolean): void;
7
13
  static initAttributes: {
8
14
  mode: string;
9
15
  theme: string;
@@ -23,6 +23,7 @@ and an `ace` property that returns the `ace` module, giving you complete access
23
23
  /*{ "parent": "Components" }*/
24
24
  import { Component as WebComponent } from 'tosijs';
25
25
  import { scriptTag } from './via-tag';
26
+ import { tosiDiff } from './diff';
26
27
  const ACE_BASE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.2/';
27
28
  const DEFAULT_THEME = 'ace/theme/tomorrow';
28
29
  const getAce = async () => {
@@ -37,6 +38,10 @@ const makeCodeEditor = async (codeElement, mode = 'html', options = {}, theme =
37
38
  tabSize: 2,
38
39
  useSoftTabs: true,
39
40
  useWorker: false,
41
+ // Wrap wide lines instead of scrolling horizontally; indentedSoftWrap keeps
42
+ // wrapped continuations aligned under the line's indent.
43
+ wrap: true,
44
+ indentedSoftWrap: true,
40
45
  ...options,
41
46
  });
42
47
  editor.setTheme(theme);
@@ -58,6 +63,44 @@ export class CodeEditor extends WebComponent {
58
63
  this.editor.session.getUndoManager().reset();
59
64
  }
60
65
  }
66
+ // Baseline for `showDiff()` — the version to diff the current `value` against.
67
+ // Defaults to the current value (no diff) until a caller sets it.
68
+ _original;
69
+ get original() {
70
+ return this._original ?? this.value;
71
+ }
72
+ set original(text) {
73
+ this._original = text;
74
+ }
75
+ // Diff overlay — deliberately built only on the editor's public surface
76
+ // (`value` + `original`) and the tosi-diff component, never the underlying
77
+ // editor's API, so it survives a future Ace → CodeMirror swap untouched.
78
+ diffOverlay;
79
+ get showingDiff() {
80
+ return this.diffOverlay !== undefined && !this.diffOverlay.hidden;
81
+ }
82
+ showDiff(on) {
83
+ if (on) {
84
+ if (this.diffOverlay === undefined) {
85
+ this.diffOverlay = tosiDiff({
86
+ style: {
87
+ position: 'absolute',
88
+ inset: '0',
89
+ zIndex: '5',
90
+ overflow: 'auto',
91
+ background: 'var(--tosi-diff-bg, var(--background, #fff))',
92
+ },
93
+ });
94
+ this.append(this.diffOverlay);
95
+ }
96
+ this.diffOverlay.original = this.original;
97
+ this.diffOverlay.modified = this.value;
98
+ this.diffOverlay.hidden = false;
99
+ }
100
+ else if (this.diffOverlay !== undefined) {
101
+ this.diffOverlay.hidden = true;
102
+ }
103
+ }
61
104
  static initAttributes = {
62
105
  mode: 'javascript',
63
106
  theme: DEFAULT_THEME,
package/dist/diff.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { Component, ElementCreator, PartsMap } from 'tosijs';
2
+ export type DiffOp = 'context' | 'add' | 'remove';
3
+ export interface DiffLine {
4
+ op: DiffOp;
5
+ text: string;
6
+ }
7
+ /**
8
+ * Line-level diff of two strings via longest-common-subsequence, returning each
9
+ * line tagged `context` (unchanged), `remove` (only in `before`), or `add` (only
10
+ * in `after`), in display order.
11
+ */
12
+ export declare function diffLines(before: string, after: string): DiffLine[];
13
+ interface DiffParts extends PartsMap {
14
+ body: HTMLElement;
15
+ }
16
+ export declare class TosiDiff extends Component<DiffParts> {
17
+ static preferredTagName: string;
18
+ static initAttributes: {
19
+ original: string;
20
+ modified: string;
21
+ };
22
+ static shadowStyleSpec: {
23
+ ':host': {
24
+ display: string;
25
+ overflow: string;
26
+ font: string;
27
+ background: string;
28
+ color: string;
29
+ };
30
+ ':host([hidden])': {
31
+ display: string;
32
+ };
33
+ '.diff-line': {
34
+ display: string;
35
+ gridTemplateColumns: string;
36
+ whiteSpace: string;
37
+ wordBreak: string;
38
+ };
39
+ '.diff-line .marker': {
40
+ textAlign: string;
41
+ userSelect: string;
42
+ opacity: string;
43
+ };
44
+ '.diff-add': {
45
+ background: string;
46
+ };
47
+ '.diff-add .marker': {
48
+ color: string;
49
+ };
50
+ '.diff-remove': {
51
+ background: string;
52
+ };
53
+ '.diff-remove .marker': {
54
+ color: string;
55
+ };
56
+ };
57
+ content: () => HTMLDivElement[];
58
+ render(): void;
59
+ }
60
+ export declare const tosiDiff: ElementCreator<TosiDiff>;
61
+ export {};
package/dist/diff.js ADDED
@@ -0,0 +1,132 @@
1
+ /*#
2
+ # diff
3
+
4
+ `<tosi-diff>` renders a unified, line-by-line diff between two strings — the kind
5
+ of view you'd see in a code review, with removed lines marked `-` and added
6
+ lines marked `+`.
7
+
8
+ Set its `original` and `modified` properties (or attributes) to the two versions
9
+ of the text; it recomputes and re-renders. (They're named `original`/`modified`
10
+ rather than `before`/`after` to avoid the native `Element.before()`/`after()`
11
+ methods.)
12
+
13
+ ```js
14
+ const { tosiDiff } = tosijsui
15
+
16
+ preview.append(
17
+ tosiDiff({
18
+ original: 'one\ntwo\nthree\nfour',
19
+ modified: 'one\nTWO\nthree\nfour\nfive',
20
+ style: { width: '100%', height: '100%' },
21
+ })
22
+ )
23
+ ```
24
+
25
+ The diff is computed with a longest-common-subsequence pass over the lines, so
26
+ unchanged lines are shown as context and only genuine insertions/deletions are
27
+ highlighted. The pure `diffLines(before, after)` function is exported too, if you
28
+ just want the data.
29
+ */
30
+ /*{ "parent": "Components" }*/
31
+ import { Component, elements } from 'tosijs';
32
+ const { div, span } = elements;
33
+ /**
34
+ * Line-level diff of two strings via longest-common-subsequence, returning each
35
+ * line tagged `context` (unchanged), `remove` (only in `before`), or `add` (only
36
+ * in `after`), in display order.
37
+ */
38
+ export function diffLines(before, after) {
39
+ const a = before.split('\n');
40
+ const b = after.split('\n');
41
+ const m = a.length;
42
+ const n = b.length;
43
+ // lcs[i][j] = length of the LCS of a[i:] and b[j:]
44
+ const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
45
+ for (let i = m - 1; i >= 0; i -= 1) {
46
+ for (let j = n - 1; j >= 0; j -= 1) {
47
+ lcs[i][j] =
48
+ a[i] === b[j]
49
+ ? lcs[i + 1][j + 1] + 1
50
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
51
+ }
52
+ }
53
+ const out = [];
54
+ let i = 0;
55
+ let j = 0;
56
+ while (i < m && j < n) {
57
+ if (a[i] === b[j]) {
58
+ out.push({ op: 'context', text: a[i] });
59
+ i += 1;
60
+ j += 1;
61
+ }
62
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
63
+ out.push({ op: 'remove', text: a[i] });
64
+ i += 1;
65
+ }
66
+ else {
67
+ out.push({ op: 'add', text: b[j] });
68
+ j += 1;
69
+ }
70
+ }
71
+ while (i < m) {
72
+ out.push({ op: 'remove', text: a[i] });
73
+ i += 1;
74
+ }
75
+ while (j < n) {
76
+ out.push({ op: 'add', text: b[j] });
77
+ j += 1;
78
+ }
79
+ return out;
80
+ }
81
+ const MARKER = { context: ' ', add: '+', remove: '-' };
82
+ export class TosiDiff extends Component {
83
+ static preferredTagName = 'tosi-diff';
84
+ // `before`/`after` would collide with the native Element.before()/after()
85
+ // methods, so the component props are `original`/`modified`.
86
+ static initAttributes = {
87
+ original: '',
88
+ modified: '',
89
+ };
90
+ static shadowStyleSpec = {
91
+ ':host': {
92
+ display: 'block',
93
+ overflow: 'auto',
94
+ font: 'var(--tosi-code-font, 12px/1.5 monospace)',
95
+ background: 'var(--tosi-diff-bg, #fff)',
96
+ color: 'var(--tosi-diff-color, #222)',
97
+ },
98
+ // `:host{display:block}` would otherwise beat the UA `[hidden]` rule, so the
99
+ // element couldn't be hidden via the attribute — restore that.
100
+ ':host([hidden])': {
101
+ display: 'none',
102
+ },
103
+ '.diff-line': {
104
+ display: 'grid',
105
+ gridTemplateColumns: '1.5em 1fr',
106
+ whiteSpace: 'pre-wrap',
107
+ wordBreak: 'break-word',
108
+ },
109
+ '.diff-line .marker': {
110
+ textAlign: 'center',
111
+ userSelect: 'none',
112
+ opacity: '0.5',
113
+ },
114
+ '.diff-add': {
115
+ background: 'var(--tosi-diff-add-bg, #e6ffed)',
116
+ },
117
+ '.diff-add .marker': { color: 'var(--tosi-diff-add-color, #22863a)' },
118
+ '.diff-remove': {
119
+ background: 'var(--tosi-diff-remove-bg, #ffeef0)',
120
+ },
121
+ '.diff-remove .marker': { color: 'var(--tosi-diff-remove-color, #cb2431)' },
122
+ };
123
+ content = () => [div({ part: 'body' })];
124
+ render() {
125
+ super.render();
126
+ const lines = diffLines(this.original, this.modified);
127
+ this.parts.body.replaceChildren(...lines.map((line) => div({ class: `diff-line diff-${line.op}` }, span({ class: 'marker' }, MARKER[line.op]),
128
+ // Non-breaking fallback so empty lines keep their row height.
129
+ span({ class: 'text' }, line.text === '' ? ' ' : line.text))));
130
+ }
131
+ }
132
+ export const tosiDiff = TosiDiff.elementCreator();
@@ -160,6 +160,8 @@ import { tosiSidenav, TosiSidenav } from './side-nav';
160
160
  import { icons } from './icons';
161
161
  import { tosiLocalized } from './localize';
162
162
  import { popMenu } from './menu';
163
+ import { codeEditor } from './code-editor';
164
+ import { tosiDiff } from './diff';
163
165
  const { div, span, a, header, button, template, input, h2, details, summary, ul, li } = elements;
164
166
  // Test colors
165
167
  const testColor = {
@@ -169,6 +171,8 @@ const testColor = {
169
171
  };
170
172
  // Test indicator styles - widget inherits button styles from base stylesheet
171
173
  const testIndicatorStyleSpec = {
174
+ '.view-source': { opacity: '0.9' },
175
+ '.view-source:hover': { opacity: '1' },
172
176
  '@keyframes test-pulse': {
173
177
  '0%, 100%': { opacity: '1' },
174
178
  '50%': { opacity: '0.7' },
@@ -402,8 +406,28 @@ export function createDocBrowser(options) {
402
406
  // back/forward (the host owns history, if any).
403
407
  if (!memoryRouting) {
404
408
  window.addEventListener('popstate', () => {
409
+ // Back/forward while the source editor is open with unsaved changes: the
410
+ // URL already moved, so on "keep editing" we push it back to match the
411
+ // still-open editor; on discard we close the editor and navigate.
412
+ if (editUI) {
413
+ if (editorHasUnsavedChanges() &&
414
+ !window.confirm('Discard unsaved changes to the source?')) {
415
+ const href = hrefFor(String(editUI.doc.filename));
416
+ window.history.pushState({ href }, '', href);
417
+ return;
418
+ }
419
+ closeEditor();
420
+ }
405
421
  navigateTo(filenameFromLocation());
406
422
  });
423
+ // Reload / tab-close / external navigation: native "leave site?" prompt when
424
+ // there are unsaved source edits (the only guard the browser allows here).
425
+ window.addEventListener('beforeunload', (event) => {
426
+ if (editorHasUnsavedChanges()) {
427
+ event.preventDefault();
428
+ event.returnValue = '';
429
+ }
430
+ });
407
431
  }
408
432
  const headerContent = [
409
433
  button({
@@ -496,7 +520,9 @@ export function createDocBrowser(options) {
496
520
  else {
497
521
  docContent.innerHTML = renderDocMarkdown(doc.text);
498
522
  }
499
- LiveExample.insertExamples(docContent, context);
523
+ // Stamp each example with its source file (for the source↔doc map). doc.path
524
+ // is the extracted file (.md, or a source file with doc comments).
525
+ LiveExample.insertExamples(docContent, context, doc.path || undefined);
500
526
  if (routing === 'path') {
501
527
  document.title = projectName
502
528
  ? `${doc.title} — ${projectName}`
@@ -515,6 +541,10 @@ export function createDocBrowser(options) {
515
541
  // the onRouteChange callback for memory) and then render the doc. Every nav
516
542
  // click funnels through here so memory mode never touches window.history.
517
543
  const go = (filename) => {
544
+ // If the source editor is open with unsaved changes, confirm before leaving;
545
+ // on confirm it discards + closes, otherwise navigation is aborted.
546
+ if (!confirmLeaveEditor())
547
+ return;
518
548
  if (memoryRouting) {
519
549
  onRouteChange?.(slugFor(filename));
520
550
  }
@@ -524,6 +554,220 @@ export function createDocBrowser(options) {
524
554
  }
525
555
  navigateTo(filename);
526
556
  };
557
+ // ── Edit page source (Foundation B / #4) ──────────────────────────────────
558
+ // The "view source" affordance opens a menu: edit the whole source file in a
559
+ // code editor that fills the content area, preview the result in-browser, and
560
+ // save (dev: write the repo file via /__docstore/source; the watcher rebuilds)
561
+ // or download it. Source is read from the dev endpoint, falling back to GitHub
562
+ // raw, so editing works on the deployed site too (save there = download).
563
+ const editorModeFor = (p) => p.endsWith('.md')
564
+ ? 'markdown'
565
+ : p.endsWith('.css')
566
+ ? 'css'
567
+ : p.endsWith('.ts') || p.endsWith('.tjs')
568
+ ? 'typescript'
569
+ : 'javascript';
570
+ // Pure mirror of docs.ts extraction: a .md *is* the markdown; a source file is
571
+ // the concatenation of its doc-comment blocks. Lets us preview an edit in the
572
+ // browser with no rebuild.
573
+ const docMarkdownFromSource = (content, p) => {
574
+ if (p.endsWith('.md'))
575
+ return content;
576
+ const blocks = content.match(/\/\*#[\s\S]+?\*\//g) || [];
577
+ return blocks.map((s) => s.substring(3, s.length - 2).trim()).join('\n\n');
578
+ };
579
+ const githubRawUrl = (p) => {
580
+ const gh = projectLinks.github;
581
+ if (!gh || !p)
582
+ return null;
583
+ const m = gh.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
584
+ return m ? `https://raw.githubusercontent.com/${m[1]}/${m[2]}/main/${p}` : null;
585
+ };
586
+ const loadSource = async (p) => {
587
+ try {
588
+ const r = await fetch(`/__docstore/source?file=${encodeURIComponent(p)}`);
589
+ if (r.ok)
590
+ return await r.text();
591
+ }
592
+ catch {
593
+ // dev endpoint not available — fall through to GitHub raw
594
+ }
595
+ const raw = githubRawUrl(p);
596
+ if (raw) {
597
+ try {
598
+ const r = await fetch(raw);
599
+ if (r.ok)
600
+ return await r.text();
601
+ }
602
+ catch {
603
+ // offline / no network
604
+ }
605
+ }
606
+ return null;
607
+ };
608
+ const saveSourceToDisk = async (p, content) => {
609
+ try {
610
+ const r = await fetch('/__docstore/source', {
611
+ method: 'POST',
612
+ headers: { 'Content-Type': 'application/json' },
613
+ body: JSON.stringify({ file: p, content }),
614
+ });
615
+ return r.ok;
616
+ }
617
+ catch {
618
+ return false;
619
+ }
620
+ };
621
+ const downloadText = (filename, content) => {
622
+ const url = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
623
+ const link = a({ href: url, download: filename });
624
+ link.click();
625
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
626
+ };
627
+ let editUI = null;
628
+ const editorHasUnsavedChanges = () => editUI !== null && editUI.editor.value !== editUI.original;
629
+ // Tear the editor down without re-rendering the current doc (the caller is
630
+ // about to navigate). exitEditSource() restores the current doc instead.
631
+ const closeEditor = () => {
632
+ if (!editUI)
633
+ return;
634
+ editUI.editor.remove();
635
+ editUI = null;
636
+ docContent.style.display = '';
637
+ };
638
+ // Called before in-app navigation: if the editor is open with unsaved changes,
639
+ // confirm discarding them. Returns false to abort navigation.
640
+ const confirmLeaveEditor = () => {
641
+ if (!editUI)
642
+ return true;
643
+ if (editorHasUnsavedChanges() &&
644
+ !window.confirm('Discard unsaved changes to the source?')) {
645
+ return false;
646
+ }
647
+ closeEditor();
648
+ return true;
649
+ };
650
+ // Switch the source editor between the raw editor, the rendered preview, and a
651
+ // diff of the edits against the loaded original. preview/diff render into the
652
+ // doc-content area; edit shows the CodeEditor.
653
+ const setSourceView = (view) => {
654
+ if (!editUI)
655
+ return;
656
+ editUI.view = view;
657
+ if (view === 'preview') {
658
+ docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path));
659
+ LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
660
+ }
661
+ else if (view === 'diff') {
662
+ docContent.replaceChildren(tosiDiff({
663
+ original: editUI.original,
664
+ modified: editUI.editor.value,
665
+ style: { display: 'block', width: '100%', height: '100%' },
666
+ }));
667
+ }
668
+ docContent.style.display = view === 'edit' ? 'none' : '';
669
+ editUI.editor.style.display = view === 'edit' ? 'block' : 'none';
670
+ };
671
+ const exitEditSource = () => {
672
+ if (!editUI)
673
+ return;
674
+ const filename = String(app.currentDoc.filename);
675
+ closeEditor();
676
+ navigateTo(filename); // restore the canonical rendered doc
677
+ };
678
+ const saveSourceEdit = async () => {
679
+ if (!editUI)
680
+ return;
681
+ const { doc, editor } = editUI;
682
+ const ok = await saveSourceToDisk(doc.path, editor.value);
683
+ if (ok) {
684
+ editUI.original = editor.value; // saved — this is the new clean baseline
685
+ setSourceView('preview'); // the watcher also rebuilds in the background
686
+ }
687
+ else {
688
+ // No write endpoint (deployed site) — hand the file back for the repo.
689
+ downloadText(doc.path.split('/').pop() || 'source.txt', editor.value);
690
+ }
691
+ };
692
+ const enterEditSource = async (doc) => {
693
+ if (editUI)
694
+ return;
695
+ const content = await loadSource(doc.path);
696
+ if (content === null) {
697
+ window.alert(`Could not load source for ${doc.path}`);
698
+ return;
699
+ }
700
+ const editor = codeEditor({ mode: editorModeFor(doc.path) });
701
+ // No toolbar — the Source menu carries the edit controls (it adapts while
702
+ // editing), so the editor fills the whole content area.
703
+ editor.style.cssText = 'display:block; width:100%; height:100%; border:none;';
704
+ editor.value = content;
705
+ const container = docContent.parentElement;
706
+ container.append(editor);
707
+ editUI = { editor, doc, view: 'edit', original: content };
708
+ setSourceView('edit');
709
+ };
710
+ const openSourceMenu = (target) => {
711
+ const doc = docs.find((d) => String(d.filename) === String(app.currentDoc.filename));
712
+ if (!doc)
713
+ return;
714
+ const fileName = (path) => path.split('/').pop() || path;
715
+ // While editing, the Source menu becomes the editor's controls (no separate
716
+ // toolbar). Otherwise it offers entry points: edit, view on GitHub, download.
717
+ const menuItems = editUI
718
+ ? [
719
+ ...(editUI.view !== 'edit'
720
+ ? [{ caption: 'Edit', icon: 'edit', action: () => setSourceView('edit') }]
721
+ : []),
722
+ ...(editUI.view !== 'preview'
723
+ ? [{ caption: 'Preview', icon: 'eye', action: () => setSourceView('preview') }]
724
+ : []),
725
+ {
726
+ caption: 'View changes',
727
+ icon: 'code',
728
+ action: () => setSourceView('diff'),
729
+ enabled: () => editorHasUnsavedChanges(),
730
+ },
731
+ null,
732
+ { caption: 'Save to source', icon: 'save', action: () => void saveSourceEdit() },
733
+ {
734
+ caption: 'Download',
735
+ icon: 'download',
736
+ action: () => downloadText(fileName(editUI.doc.path), editUI.editor.value),
737
+ },
738
+ null,
739
+ { caption: 'Close editor', icon: 'x', action: exitEditSource },
740
+ ]
741
+ : [
742
+ { caption: 'Edit page source', icon: 'edit', action: () => void enterEditSource(doc) },
743
+ ...(projectLinks.github && doc.path && doc.path !== 'README.md'
744
+ ? [
745
+ {
746
+ caption: 'View on GitHub',
747
+ icon: 'github',
748
+ action: () => {
749
+ window.open(`${projectLinks.github}/blob/main/${doc.path}`, '_blank');
750
+ },
751
+ },
752
+ ]
753
+ : []),
754
+ {
755
+ caption: 'Download source',
756
+ icon: 'download',
757
+ action: () => void loadSource(doc.path).then((c) => {
758
+ if (c !== null)
759
+ downloadText(fileName(doc.path), c);
760
+ }),
761
+ },
762
+ ];
763
+ popMenu({
764
+ target,
765
+ // The trigger is position:fixed at the top-right; `auto` placement opens
766
+ // upward into zero space and collapses the menu, so open below-left.
767
+ position: 'sw',
768
+ menuItems,
769
+ });
770
+ };
527
771
  // ── Hierarchical nav (path routing) ───────────────────────────────────────
528
772
  // Build nested <details> from the doc tree; current-highlight, test status,
529
773
  // search visibility, and auto-open are applied imperatively by refreshNav.
@@ -686,49 +930,47 @@ export function createDocBrowser(options) {
686
930
  overflowY: 'scroll',
687
931
  height: '100%',
688
932
  },
689
- }, a({
933
+ }, button({
690
934
  class: 'view-source',
691
- target: '_blank',
692
935
  style: {
693
- display: projectLinks.github ? 'flex' : 'none',
936
+ display: 'flex',
694
937
  alignItems: 'center',
695
938
  gap: '6px',
696
939
  position: 'fixed',
697
940
  top: 'calc(var(--xin-header-height, 60px) + 5px)',
698
941
  right: '5px',
942
+ // Above the code editor (which fills the content area); below the
943
+ // popped menu (z 1002).
944
+ zIndex: '100',
699
945
  fontSize: '0.875em',
700
946
  color: 'var(--brand-color, inherit)',
701
- opacity: '0.7',
702
- borderBottom: 'none',
947
+ // Solid, theme-aware chip so it stays legible over the editor.
948
+ background: 'var(--background, white)',
949
+ border: 'none',
950
+ borderRadius: '4px',
951
+ padding: '3px 8px',
952
+ boxShadow: '0 1px 4px #0003',
953
+ cursor: 'pointer',
703
954
  transition: 'opacity 0.2s ease',
704
955
  },
705
- onMouseenter(event) {
706
- ;
707
- event.target.style.opacity = '0.9';
708
- },
709
- onMouseleave(event) {
710
- ;
711
- event.target.style.opacity = '0.7';
956
+ // Opacity + :hover live in CSS (.view-source) — a JS hover handler
957
+ // here would fade the delegation root (the whole page), not the button.
958
+ onClick(event) {
959
+ // tosijs delegates clicks, so event.currentTarget is the delegation
960
+ // root, not the button — resolve the real trigger for menu anchoring.
961
+ const btn = event.target.closest('.view-source');
962
+ if (btn)
963
+ openSourceMenu(btn);
712
964
  },
713
965
  bind: {
714
966
  value: app.currentDoc,
715
967
  binding(element, doc) {
716
- if (projectLinks.github &&
717
- doc.path &&
718
- doc.path !== 'README.md') {
719
- element.href = `${projectLinks.github}/blob/main/${doc.path}`;
720
- element.style.display = 'flex';
721
- }
722
- else {
723
- element.style.display = 'none';
724
- }
968
+ // Show when there's a source file to edit/view (any doc with a
969
+ // path); the menu handles dev-vs-GitHub availability per item.
970
+ element.style.display = doc.path ? 'flex' : 'none';
725
971
  },
726
972
  },
727
- }, icons.github({
728
- style: {
729
- _xinIconSize: 16,
730
- },
731
- }), 'View source on GitHub'), docContent)));
973
+ }, icons.code({ style: { _xinIconSize: 16 } }), 'Source'), docContent)));
732
974
  // Render the landing doc (adopts pre-rendered HTML when hydrating).
733
975
  showDoc(currentDoc);
734
976
  // Inject test indicator styles
@@ -915,10 +1157,14 @@ export function createDocBrowser(options) {
915
1157
  return;
916
1158
  }
917
1159
  const currentFilename = String(app.currentDoc.filename);
918
- // Create a hidden iframe that loads the full page
1160
+ // Create a hidden iframe that loads the full page. Keep its real 800x600
1161
+ // layout size (layout-dependent example tests need it) but park it
1162
+ // off-screen rather than rely on opacity:0 over the visible page — Chromium
1163
+ // (and haltija on top of it) still composites a 0-opacity layer at 0,0,
1164
+ // which flashes as the frame navigates page to page.
919
1165
  const testFrame = document.createElement('iframe');
920
1166
  testFrame.style.cssText =
921
- 'position: fixed; left: 0; top: 0; width: 800px; height: 600px; opacity: 0; pointer-events: none;';
1167
+ 'position: fixed; left: -10000px; top: 0; width: 800px; height: 600px; opacity: 0; pointer-events: none;';
922
1168
  document.body.appendChild(testFrame);
923
1169
  // Listen for test results posted from the iframe
924
1170
  const messageHandler = (event) => {
@@ -241,7 +241,10 @@ export function docSystemStyleSpec(theme = {}) {
241
241
  overflow: 'hidden',
242
242
  position: 'relative',
243
243
  },
244
- svg: {
244
+ // Scope the icon styling (currentColor fill, click-through) to actual icons
245
+ // and SVGs inside buttons — a blanket `svg` rule sets pointer-events:none on
246
+ // every SVG, which breaks interactive SVG-based UIs (maps, charts, diagrams).
247
+ 'svg.tosi-icon, button svg': {
245
248
  fill: 'currentColor',
246
249
  pointerEvents: 'none',
247
250
  },
@@ -369,6 +369,14 @@ export class TosiDocSystem extends Component {
369
369
  header.append(this.settingsButton());
370
370
  }
371
371
  this.replaceChildren(this.browser);
372
+ // Hydration done: reveal the page (the generated <head> hid it to mask the
373
+ // static→live swap). Only the page-owning instance touches document.body;
374
+ // a nested demo must not. Next frame so the browser has painted first.
375
+ if (!nested && typeof document !== 'undefined') {
376
+ requestAnimationFrame(() => {
377
+ document.body.style.opacity = '1';
378
+ });
379
+ }
372
380
  }
373
381
  }
374
382
  export const tosiDocSystem = TosiDocSystem.elementCreator();