@bendyline/squisq-react 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/DocPlayer.d.ts +9 -2
  2. package/dist/DocPlayer.d.ts.map +1 -1
  3. package/dist/DocPlayer.js +27 -8
  4. package/dist/DocPlayer.js.map +1 -1
  5. package/dist/InlineAudioPlayer.d.ts +15 -0
  6. package/dist/InlineAudioPlayer.d.ts.map +1 -0
  7. package/dist/InlineAudioPlayer.js +22 -0
  8. package/dist/InlineAudioPlayer.js.map +1 -0
  9. package/dist/InlineVideoPlayer.d.ts +21 -0
  10. package/dist/InlineVideoPlayer.d.ts.map +1 -0
  11. package/dist/InlineVideoPlayer.js +29 -0
  12. package/dist/InlineVideoPlayer.js.map +1 -0
  13. package/dist/LinearDocView.d.ts +28 -2
  14. package/dist/LinearDocView.d.ts.map +1 -1
  15. package/dist/LinearDocView.js +159 -24
  16. package/dist/LinearDocView.js.map +1 -1
  17. package/dist/MarkdownRenderer.d.ts.map +1 -1
  18. package/dist/MarkdownRenderer.js +123 -4
  19. package/dist/MarkdownRenderer.js.map +1 -1
  20. package/dist/SocialCaptionOverlay.d.ts.map +1 -1
  21. package/dist/SocialCaptionOverlay.js +3 -2
  22. package/dist/SocialCaptionOverlay.js.map +1 -1
  23. package/dist/__tests__/JsonView.test.d.ts +2 -0
  24. package/dist/__tests__/JsonView.test.d.ts.map +1 -0
  25. package/dist/__tests__/JsonView.test.js +93 -0
  26. package/dist/__tests__/JsonView.test.js.map +1 -0
  27. package/dist/__tests__/LinearDocView.test.js +90 -0
  28. package/dist/__tests__/LinearDocView.test.js.map +1 -1
  29. package/dist/hooks/MediaContext.d.ts.map +1 -1
  30. package/dist/hooks/MediaContext.js +13 -7
  31. package/dist/hooks/MediaContext.js.map +1 -1
  32. package/dist/hooks/useAutoSurface.d.ts +11 -0
  33. package/dist/hooks/useAutoSurface.d.ts.map +1 -0
  34. package/dist/hooks/useAutoSurface.js +24 -0
  35. package/dist/hooks/useAutoSurface.js.map +1 -0
  36. package/dist/index.d.ts +8 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/jsonView/JsonView.d.ts +26 -0
  41. package/dist/jsonView/JsonView.d.ts.map +1 -0
  42. package/dist/jsonView/JsonView.js +12 -0
  43. package/dist/jsonView/JsonView.js.map +1 -0
  44. package/dist/jsonView/RenderNode.d.ts +22 -0
  45. package/dist/jsonView/RenderNode.d.ts.map +1 -0
  46. package/dist/jsonView/RenderNode.js +30 -0
  47. package/dist/jsonView/RenderNode.js.map +1 -0
  48. package/dist/jsonView/index.d.ts +3 -0
  49. package/dist/jsonView/index.d.ts.map +1 -0
  50. package/dist/jsonView/index.js +2 -0
  51. package/dist/jsonView/index.js.map +1 -0
  52. package/dist/jsonView/useJsonViewTokens.d.ts +14 -0
  53. package/dist/jsonView/useJsonViewTokens.d.ts.map +1 -0
  54. package/dist/jsonView/useJsonViewTokens.js +34 -0
  55. package/dist/jsonView/useJsonViewTokens.js.map +1 -0
  56. package/dist/jsonView/viewers.d.ts +30 -0
  57. package/dist/jsonView/viewers.d.ts.map +1 -0
  58. package/dist/jsonView/viewers.js +226 -0
  59. package/dist/jsonView/viewers.js.map +1 -0
  60. package/dist/squisq-player.css +1 -1
  61. package/dist/squisq-player.css.map +1 -1
  62. package/dist/squisq-player.global.js +32 -8
  63. package/dist/squisq-player.global.js.map +1 -1
  64. package/dist/standalone-source.js +1 -1
  65. package/dist/types.d.ts +5 -1
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/package.json +8 -7
  69. package/src/DocPlayer.tsx +44 -8
  70. package/src/InlineAudioPlayer.tsx +46 -0
  71. package/src/InlineVideoPlayer.tsx +70 -0
  72. package/src/LinearDocView.tsx +198 -26
  73. package/src/MarkdownRenderer.tsx +189 -12
  74. package/src/SocialCaptionOverlay.tsx +3 -2
  75. package/src/__tests__/JsonView.test.tsx +111 -0
  76. package/src/__tests__/LinearDocView.test.tsx +101 -0
  77. package/src/hooks/MediaContext.tsx +15 -8
  78. package/src/hooks/useAutoSurface.ts +33 -0
  79. package/src/index.ts +10 -0
  80. package/src/jsonView/JsonView.tsx +51 -0
  81. package/src/jsonView/RenderNode.tsx +51 -0
  82. package/src/jsonView/index.ts +2 -0
  83. package/src/jsonView/json-view.css +206 -0
  84. package/src/jsonView/useJsonViewTokens.ts +57 -0
  85. package/src/jsonView/viewers.tsx +343 -0
  86. package/src/styles/doc-animations.css +36 -0
  87. package/src/styles/index.css +7 -0
  88. package/src/types.ts +5 -1
@@ -21,8 +21,12 @@ import type {
21
21
  MarkdownListItem,
22
22
  MarkdownTableRow,
23
23
  MarkdownTableCell,
24
+ HtmlNode,
25
+ HtmlElement,
24
26
  } from '@bendyline/squisq/markdown';
25
27
  import { useMediaUrl } from './hooks/MediaContext';
28
+ import { InlineVideoPlayer } from './InlineVideoPlayer.js';
29
+ import { InlineAudioPlayer } from './InlineAudioPlayer.js';
26
30
 
27
31
  // ── Props ──────────────────────────────────────────────────────────
28
32
 
@@ -40,8 +44,25 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
40
44
  return nodes.map((node, i) => {
41
45
  const key = `${keyPrefix}i${i}`;
42
46
  switch (node.type) {
43
- case 'text':
44
- return <Fragment key={key}>{node.value}</Fragment>;
47
+ case 'text': {
48
+ // Soft breaks (newlines without two trailing spaces) become \n in
49
+ // text nodes. HTML collapses \n to whitespace, which loses the visual
50
+ // line break the author wanted, so render <br> for each newline.
51
+ if (!node.value.includes('\n')) {
52
+ return <Fragment key={key}>{node.value}</Fragment>;
53
+ }
54
+ const parts = node.value.split('\n');
55
+ return (
56
+ <Fragment key={key}>
57
+ {parts.map((part, j) => (
58
+ <Fragment key={j}>
59
+ {j > 0 && <br />}
60
+ {part}
61
+ </Fragment>
62
+ ))}
63
+ </Fragment>
64
+ );
65
+ }
45
66
 
46
67
  case 'emphasis':
47
68
  return (
@@ -101,12 +122,23 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
101
122
  );
102
123
 
103
124
  case 'htmlInline':
125
+ // Fast path: no <video>/<audio> in the subtree → use the original
126
+ // rawHtml passthrough (preserves arbitrary HTML for custom embeds).
127
+ if (!containsMediaTag(node.htmlChildren)) {
128
+ return (
129
+ <span
130
+ key={key}
131
+ className="squisq-md-html-inline"
132
+ dangerouslySetInnerHTML={{ __html: node.rawHtml }}
133
+ />
134
+ );
135
+ }
136
+ // Otherwise reconstruct the subtree as React so <video>/<audio>
137
+ // go through MediaContext-aware player components.
104
138
  return (
105
- <span
106
- key={key}
107
- className="squisq-md-html-inline"
108
- dangerouslySetInnerHTML={{ __html: node.rawHtml }}
109
- />
139
+ <span key={key} className="squisq-md-html-inline">
140
+ {renderHtmlNodes(node.htmlChildren, `${key}h`)}
141
+ </span>
110
142
  );
111
143
 
112
144
  case 'footnoteReference':
@@ -138,6 +170,20 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
138
170
  </span>
139
171
  );
140
172
 
173
+ case 'mention':
174
+ return (
175
+ <span
176
+ key={key}
177
+ className="squisq-md-mention mention"
178
+ data-mention="true"
179
+ data-kind={node.targetKind}
180
+ data-id={node.targetId}
181
+ data-label={node.displayName}
182
+ >
183
+ @{node.displayName}
184
+ </span>
185
+ );
186
+
141
187
  default:
142
188
  return null;
143
189
  }
@@ -200,12 +246,23 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
200
246
  return renderTable(node.children, node.align, key);
201
247
 
202
248
  case 'htmlBlock':
249
+ // Fast path: no <video>/<audio> → preserve the existing rawHtml
250
+ // passthrough so arbitrary HTML embeds still survive verbatim.
251
+ if (!containsMediaTag(node.htmlChildren)) {
252
+ return (
253
+ <div
254
+ key={key}
255
+ className="squisq-md-html-block"
256
+ dangerouslySetInnerHTML={{ __html: node.rawHtml }}
257
+ />
258
+ );
259
+ }
260
+ // Otherwise reconstruct subtree as React so <video>/<audio>
261
+ // route through the player components and resolve via MediaContext.
203
262
  return (
204
- <div
205
- key={key}
206
- className="squisq-md-html-block"
207
- dangerouslySetInnerHTML={{ __html: node.rawHtml }}
208
- />
263
+ <div key={key} className="squisq-md-html-block">
264
+ {renderHtmlNodes(node.htmlChildren, `${key}h`)}
265
+ </div>
209
266
  );
210
267
 
211
268
  case 'math':
@@ -346,6 +403,126 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
346
403
  return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
347
404
  }
348
405
 
406
+ // ── Raw-HTML walker (intercepts <video>/<audio>) ─────────────────
407
+
408
+ /** True when the htmlElement subtree contains a tag we want to swap
409
+ * for a React component. Cheap recursive scan — lets us keep the
410
+ * `dangerouslySetInnerHTML` fast path for everything else. */
411
+ function containsMediaTag(nodes: HtmlNode[]): boolean {
412
+ for (const node of nodes) {
413
+ if (node.type !== 'htmlElement') continue;
414
+ if (node.tagName === 'video' || node.tagName === 'audio') return true;
415
+ if (containsMediaTag(node.children)) return true;
416
+ }
417
+ return false;
418
+ }
419
+
420
+ /** A pragmatic shortlist of HTML attributes the raw-HTML walker
421
+ * passes through to React when reconstructing a non-media element.
422
+ * Anything outside this list is silently dropped — the media-tag
423
+ * fast path means most authors will never hit this code, so we
424
+ * keep the surface narrow to avoid React warnings about unknown
425
+ * attributes. */
426
+ const PASSTHROUGH_ATTRS: Record<string, string> = {
427
+ // common
428
+ class: 'className',
429
+ id: 'id',
430
+ title: 'title',
431
+ style: 'style',
432
+ // media-adjacent (used when video/audio appear inside other wrappers)
433
+ width: 'width',
434
+ height: 'height',
435
+ // anchor
436
+ href: 'href',
437
+ target: 'target',
438
+ rel: 'rel',
439
+ };
440
+
441
+ function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unknown> {
442
+ const out: Record<string, unknown> = {};
443
+ for (const [name, value] of Object.entries(attrs)) {
444
+ const propName = PASSTHROUGH_ATTRS[name];
445
+ if (!propName) continue;
446
+ if (propName === 'style') {
447
+ // We can't safely parse arbitrary `style="..."` strings without
448
+ // an HTML parser; preserve as a data attribute so authors can
449
+ // see it survived round-trip without crashing React.
450
+ out['data-style'] = value;
451
+ continue;
452
+ }
453
+ out[propName] = value;
454
+ }
455
+ return out;
456
+ }
457
+
458
+ function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
459
+ if (el.tagName === 'video') {
460
+ return (
461
+ <InlineVideoPlayer
462
+ key={key}
463
+ src={el.attributes.src ?? ''}
464
+ width={el.attributes.width}
465
+ height={el.attributes.height}
466
+ poster={el.attributes.poster}
467
+ // The `controls` attribute is a boolean — present means true,
468
+ // even if its value is an empty string.
469
+ controls={'controls' in el.attributes}
470
+ preload={
471
+ el.attributes.preload === 'none' ||
472
+ el.attributes.preload === 'metadata' ||
473
+ el.attributes.preload === 'auto'
474
+ ? el.attributes.preload
475
+ : undefined
476
+ }
477
+ />
478
+ );
479
+ }
480
+ if (el.tagName === 'audio') {
481
+ return (
482
+ <InlineAudioPlayer
483
+ key={key}
484
+ src={el.attributes.src ?? ''}
485
+ controls={'controls' in el.attributes}
486
+ preload={
487
+ el.attributes.preload === 'none' ||
488
+ el.attributes.preload === 'metadata' ||
489
+ el.attributes.preload === 'auto'
490
+ ? el.attributes.preload
491
+ : undefined
492
+ }
493
+ />
494
+ );
495
+ }
496
+
497
+ const Tag = el.tagName as keyof JSX.IntrinsicElements;
498
+ const props = reactPropsFromAttrs(el.attributes);
499
+ if (el.selfClosing) {
500
+ return <Tag key={key} {...props} />;
501
+ }
502
+ return (
503
+ <Tag key={key} {...props}>
504
+ {renderHtmlNodes(el.children, `${key}c`)}
505
+ </Tag>
506
+ );
507
+ }
508
+
509
+ function renderHtmlNodes(nodes: HtmlNode[], keyPrefix: string): React.ReactNode[] {
510
+ return nodes.map((node, i) => {
511
+ const key = `${keyPrefix}${i}`;
512
+ switch (node.type) {
513
+ case 'htmlElement':
514
+ return renderHtmlElement(node, key);
515
+ case 'htmlText':
516
+ return <Fragment key={key}>{node.value}</Fragment>;
517
+ case 'htmlComment':
518
+ // Comments don't render in React; ignore.
519
+ return null;
520
+ default:
521
+ return null;
522
+ }
523
+ });
524
+ }
525
+
349
526
  // ── Main Component ─────────────────────────────────────────────────
350
527
 
351
528
  /**
@@ -17,6 +17,7 @@
17
17
  import { useMemo } from 'react';
18
18
  import type { CaptionTrack, CaptionPhrase, ViewportConfig } from '@bendyline/squisq/schemas';
19
19
  import type { Theme } from '@bendyline/squisq/schemas';
20
+ import { resolveFontFamily } from '@bendyline/squisq/schemas';
20
21
 
21
22
  /** Target words per visible chunk. */
22
23
  const TARGET_CHUNK_SIZE = 4;
@@ -196,8 +197,8 @@ export function SocialCaptionOverlay({
196
197
 
197
198
  // Theme-derived styling
198
199
  const primaryColor = theme?.colors?.primary ?? '#5b9bd5';
199
- const fontFamily = theme?.typography?.titleFontFamily
200
- ? `"${theme.typography.titleFontFamily}", system-ui, sans-serif`
200
+ const fontFamily = theme?.typography?.titleFont
201
+ ? resolveFontFamily(theme.typography.titleFont, '"PT Serif", Georgia, serif')
201
202
  : '"PT Serif", Georgia, serif';
202
203
 
203
204
  // Scale font to viewport — aim for ~5.5% of viewport height
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { JsonView } from '../jsonView';
4
+ import type { SquisqAnnotatedSchema } from '@bendyline/squisq/jsonForm';
5
+
6
+ describe('JsonView', () => {
7
+ it('renders an object schema as labeled rows', () => {
8
+ const schema: SquisqAnnotatedSchema = {
9
+ type: 'object',
10
+ properties: {
11
+ title: { type: 'string', title: 'Page Title' },
12
+ active: { type: 'boolean' },
13
+ },
14
+ };
15
+ const { container } = render(
16
+ <JsonView schema={schema} value={{ title: 'Hello', active: true }} />,
17
+ );
18
+ expect(container.textContent).toContain('Page Title');
19
+ expect(container.textContent).toContain('Hello');
20
+ expect(container.textContent).toContain('On');
21
+ });
22
+
23
+ it('renders an array of primitives as chips', () => {
24
+ const schema: SquisqAnnotatedSchema = {
25
+ type: 'array',
26
+ items: { type: 'string' },
27
+ };
28
+ const { container } = render(<JsonView schema={schema} value={['a', 'b', 'c']} />);
29
+ expect(container.querySelectorAll('.squisq-jv-chip')).toHaveLength(3);
30
+ });
31
+
32
+ it('renders an array of objects as cards with itemLabel.fromField', () => {
33
+ const schema: SquisqAnnotatedSchema = {
34
+ type: 'array',
35
+ items: {
36
+ type: 'object',
37
+ properties: {
38
+ heading: { type: 'string' },
39
+ body: { type: 'string' },
40
+ },
41
+ squisq: { itemLabel: { fromField: 'heading' } },
42
+ },
43
+ };
44
+ const { container } = render(
45
+ <JsonView
46
+ schema={schema}
47
+ value={[
48
+ { heading: 'Section A', body: 'Body A' },
49
+ { heading: 'Section B', body: 'Body B' },
50
+ ]}
51
+ />,
52
+ );
53
+ const cards = container.querySelectorAll('.squisq-jv-card');
54
+ expect(cards).toHaveLength(2);
55
+ expect(cards[0].textContent).toContain('Section A');
56
+ expect(cards[1].textContent).toContain('Section B');
57
+ });
58
+
59
+ it('renders a color value as a swatch + hex', () => {
60
+ const schema: SquisqAnnotatedSchema = {
61
+ type: 'string',
62
+ format: 'color',
63
+ };
64
+ const { container } = render(<JsonView schema={schema} value="#ff0080" />);
65
+ const swatch = container.querySelector('.squisq-jv-color__swatch') as HTMLElement | null;
66
+ expect(swatch).not.toBeNull();
67
+ expect(swatch?.style.background).toContain('rgb(255, 0, 128)');
68
+ expect(container.textContent).toContain('#ff0080');
69
+ });
70
+
71
+ it('hides fields whose squisq.hidden rule matches', () => {
72
+ const schema: SquisqAnnotatedSchema = {
73
+ type: 'object',
74
+ properties: {
75
+ showAuthor: { type: 'boolean' },
76
+ authorName: {
77
+ type: 'string',
78
+ squisq: { hidden: { field: 'showAuthor', truthy: false } },
79
+ },
80
+ },
81
+ };
82
+ const visible = render(
83
+ <JsonView schema={schema} value={{ showAuthor: true, authorName: 'Alex' }} />,
84
+ );
85
+ expect(visible.container.textContent).toContain('Alex');
86
+
87
+ const hidden = render(
88
+ <JsonView schema={schema} value={{ showAuthor: false, authorName: 'Alex' }} />,
89
+ );
90
+ expect(hidden.container.textContent).not.toContain('Alex');
91
+ });
92
+
93
+ it('renders empty values as the em-dash placeholder', () => {
94
+ const schema: SquisqAnnotatedSchema = {
95
+ type: 'object',
96
+ properties: { title: { type: 'string' } },
97
+ };
98
+ const { container } = render(<JsonView schema={schema} value={{}} />);
99
+ expect(container.textContent).toContain('—');
100
+ });
101
+
102
+ it('respects squisq.enumLabels when displaying enum values', () => {
103
+ const schema: SquisqAnnotatedSchema = {
104
+ type: 'string',
105
+ enum: ['s', 'm', 'l'],
106
+ squisq: { enumLabels: { s: 'Small', m: 'Medium', l: 'Large' } },
107
+ };
108
+ const { container } = render(<JsonView schema={schema} value="m" />);
109
+ expect(container.textContent).toContain('Medium');
110
+ });
111
+ });
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
2
2
  import { render } from '@testing-library/react';
3
3
  import { LinearDocView } from '../LinearDocView';
4
4
  import type { Doc, Block } from '@bendyline/squisq/schemas';
5
+ import { DARK_SURFACE, DEFAULT_THEME, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
5
6
  import type {
6
7
  MarkdownBlockNode,
7
8
  MarkdownInlineNode,
@@ -84,6 +85,72 @@ describe('LinearDocView', () => {
84
85
  expect(container.textContent).toContain('Section body text');
85
86
  });
86
87
 
88
+ it('feeds imageWithCaption blocks the first body image as imageSrc', () => {
89
+ // Regression: without auto-extracting imageSrc from contents, the
90
+ // template renders a layer with `src=undefined` and the SVG card
91
+ // shows a broken image in document (linear) mode.
92
+ const doc = mkDoc([
93
+ mkBlock({
94
+ id: 'img-1',
95
+ sourceHeading: {
96
+ type: 'heading',
97
+ depth: 1,
98
+ children: [text('Mike Ammerlaan')],
99
+ templateAnnotation: { template: 'imageWithCaption' },
100
+ },
101
+ contents: [paragraph({ type: 'image', url: 'mikehome_files/profile.png', alt: 'Mike' })],
102
+ }),
103
+ ]);
104
+ const { container } = render(<LinearDocView doc={doc} />);
105
+ const card = container.querySelector('.squisq-linear-card');
106
+ expect(card).toBeTruthy();
107
+ const img = card!.querySelector('image, img') as Element | null;
108
+ expect(img).toBeTruthy();
109
+ const href =
110
+ img!.getAttribute('href') ?? img!.getAttribute('xlink:href') ?? img!.getAttribute('src');
111
+ expect(href).toContain('mikehome_files/profile.png');
112
+ });
113
+
114
+ it('extracts imageSrc from raw HTML <img> (resized image)', () => {
115
+ // The WYSIWYG editor emits `<img src width>` for resized images
116
+ // because markdown shorthand has no width syntax. The linear view
117
+ // must read that form too, or every resized imageWithCaption block
118
+ // renders as a broken card.
119
+ const doc = mkDoc([
120
+ mkBlock({
121
+ id: 'img-2',
122
+ sourceHeading: {
123
+ type: 'heading',
124
+ depth: 1,
125
+ children: [text('Resized')],
126
+ templateAnnotation: { template: 'imageWithCaption' },
127
+ },
128
+ contents: [
129
+ {
130
+ type: 'htmlBlock',
131
+ rawHtml: '<img alt="resized" src="resized.png" width="194">',
132
+ htmlChildren: [
133
+ {
134
+ type: 'htmlElement',
135
+ tagName: 'img',
136
+ attributes: { src: 'resized.png', alt: 'resized', width: '194' },
137
+ children: [],
138
+ selfClosing: true,
139
+ },
140
+ ],
141
+ } as unknown as MarkdownBlockNode,
142
+ ],
143
+ }),
144
+ ]);
145
+ const { container } = render(<LinearDocView doc={doc} />);
146
+ const card = container.querySelector('.squisq-linear-card');
147
+ expect(card).toBeTruthy();
148
+ const img = card!.querySelector('image, img') as Element | null;
149
+ const href =
150
+ img?.getAttribute('href') ?? img?.getAttribute('xlink:href') ?? img?.getAttribute('src');
151
+ expect(href).toContain('resized.png');
152
+ });
153
+
87
154
  it('renders annotated block as SVG card', () => {
88
155
  const doc = mkDoc([
89
156
  mkBlock({
@@ -177,4 +244,38 @@ describe('LinearDocView', () => {
177
244
  expect(container.querySelector('[data-block-id="alpha"]')).toBeTruthy();
178
245
  expect(container.querySelector('[data-block-id="beta"]')).toBeTruthy();
179
246
  });
247
+
248
+ it('uses the theme background by default', () => {
249
+ const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
250
+ const { container } = render(<LinearDocView doc={doc} />);
251
+ const el = container.querySelector('.squisq-linear') as HTMLElement;
252
+ expect(el.style.background).toBeTruthy();
253
+ // DEFAULT_THEME has a specific background; applying LIGHT_SURFACE below
254
+ // must produce a different value to prove override is working.
255
+ expect(el.style.background).not.toBe(LIGHT_SURFACE.background);
256
+ });
257
+
258
+ it('light surface overlays the theme background', () => {
259
+ const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
260
+ const { container } = render(
261
+ <LinearDocView doc={doc} theme={DEFAULT_THEME} surface={LIGHT_SURFACE} />,
262
+ );
263
+ const el = container.querySelector('.squisq-linear') as HTMLElement;
264
+ // React inline style background may round-trip as hex or rgb; compare the
265
+ // colour-normalized value by mounting a plain div with the expected.
266
+ const probe = document.createElement('div');
267
+ probe.style.background = LIGHT_SURFACE.background;
268
+ expect(el.style.background).toBe(probe.style.background);
269
+ });
270
+
271
+ it('dark surface overlays the theme background', () => {
272
+ const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
273
+ const { container } = render(
274
+ <LinearDocView doc={doc} theme={DEFAULT_THEME} surface={DARK_SURFACE} />,
275
+ );
276
+ const el = container.querySelector('.squisq-linear') as HTMLElement;
277
+ const probe = document.createElement('div');
278
+ probe.style.background = DARK_SURFACE.background;
279
+ expect(el.style.background).toBe(probe.style.background);
280
+ });
180
281
  });
@@ -41,17 +41,24 @@ export function useMediaProvider(): MediaProvider | null {
41
41
  export function useMediaUrl(relativePath: string, basePath: string): string {
42
42
  const provider = useMediaProvider();
43
43
 
44
+ // Defensive: callers (esp. preview surfaces like InlinePreviewGutter)
45
+ // sometimes feed in template-generated layers whose `content.src` is
46
+ // undefined while the user is still authoring the block. Treat that
47
+ // as an empty string rather than crashing the whole React tree.
48
+ const safePath = typeof relativePath === 'string' ? relativePath : '';
49
+
44
50
  // For absolute/http URLs, skip resolution entirely
45
51
  const isAbsolute =
46
- relativePath.startsWith('http') ||
47
- relativePath.startsWith('/') ||
48
- relativePath.startsWith('data:') ||
49
- relativePath.startsWith('blob:');
52
+ !safePath ||
53
+ safePath.startsWith('http') ||
54
+ safePath.startsWith('/') ||
55
+ safePath.startsWith('data:') ||
56
+ safePath.startsWith('blob:');
50
57
 
51
58
  // Memoize fallback to avoid recalculating on every render
52
59
  const fallback = useMemo(
53
- () => (isAbsolute ? relativePath : `${basePath}/${relativePath}`),
54
- [isAbsolute, relativePath, basePath],
60
+ () => (isAbsolute ? safePath : `${basePath}/${safePath}`),
61
+ [isAbsolute, safePath, basePath],
55
62
  );
56
63
 
57
64
  // Fast path: no provider or absolute URL — return synchronously, skip effect entirely
@@ -66,14 +73,14 @@ export function useMediaUrl(relativePath: string, basePath: string): string {
66
73
  }
67
74
 
68
75
  let cancelled = false;
69
- provider!.resolveUrl(relativePath).then((resolved) => {
76
+ provider!.resolveUrl(safePath).then((resolved) => {
70
77
  if (!cancelled) setUrl(resolved);
71
78
  });
72
79
 
73
80
  return () => {
74
81
  cancelled = true;
75
82
  };
76
- }, [needsProvider, provider, relativePath, fallback]);
83
+ }, [needsProvider, provider, safePath, fallback]);
77
84
 
78
85
  // When provider is not needed, return fallback directly to avoid
79
86
  // the one-frame delay from the initial useState → useEffect cycle
@@ -0,0 +1,33 @@
1
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
2
+ import { DARK_SURFACE, LIGHT_SURFACE, type SurfaceScheme } from '@bendyline/squisq/schemas';
3
+
4
+ const DARK_QUERY = '(prefers-color-scheme: dark)';
5
+ const getServerSnapshot = () => LIGHT_SURFACE;
6
+
7
+ /**
8
+ * Live-track `prefers-color-scheme` and return a stable SurfaceScheme.
9
+ * `enabled: false` short-circuits to LIGHT_SURFACE (callers pass `false`
10
+ * when a static surface was provided so the hook never observes the
11
+ * media query). The `MediaQueryList` and the `subscribe`/`getSnapshot`
12
+ * callbacks are memoized so `useSyncExternalStore` doesn't resubscribe on
13
+ * every parent render.
14
+ */
15
+ export function useAutoSurface(enabled: boolean): SurfaceScheme {
16
+ const mql = useMemo(
17
+ () => (enabled && typeof window !== 'undefined' ? window.matchMedia(DARK_QUERY) : null),
18
+ [enabled],
19
+ );
20
+
21
+ const subscribe = useCallback(
22
+ (cb: () => void) => {
23
+ if (!mql) return () => {};
24
+ mql.addEventListener('change', cb);
25
+ return () => mql.removeEventListener('change', cb);
26
+ },
27
+ [mql],
28
+ );
29
+
30
+ const getSnapshot = useCallback(() => (mql?.matches ? DARK_SURFACE : LIGHT_SURFACE), [mql]);
31
+
32
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
33
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,11 @@ export { DocPlayerWithSidebar } from './DocPlayerWithSidebar.js';
11
11
  export { DocProgressBar } from './DocProgressBar.js';
12
12
  export { MarkdownRenderer } from './MarkdownRenderer.js';
13
13
  export { LinearDocView } from './LinearDocView.js';
14
+ export type { LinearDocViewProps, ImageDisplayMode } from './LinearDocView.js';
15
+ export { InlineVideoPlayer } from './InlineVideoPlayer.js';
16
+ export type { InlineVideoPlayerProps } from './InlineVideoPlayer.js';
17
+ export { InlineAudioPlayer } from './InlineAudioPlayer.js';
18
+ export type { InlineAudioPlayerProps } from './InlineAudioPlayer.js';
14
19
 
15
20
  // Layer components
16
21
  export { ImageLayer } from './layers/ImageLayer.js';
@@ -25,6 +30,7 @@ export { useAudioSync } from './hooks/useAudioSync.js';
25
30
  export { useDocPlayback } from './hooks/useDocPlayback.js';
26
31
  export { useViewportOrientation } from './hooks/useViewportOrientation.js';
27
32
  export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
33
+ export { useAutoSurface } from './hooks/useAutoSurface.js';
28
34
 
29
35
  // Types
30
36
  export type { AudioProvider, AudioState, AudioActions } from './hooks/AudioProvider.js';
@@ -48,3 +54,7 @@ export { formatTime } from './types.js';
48
54
 
49
55
  // Utilities
50
56
  export { getAnimationStyle, getTransitionClass } from './utils/animationUtils.js';
57
+
58
+ // JSON Form — read-only viewer
59
+ export { JsonView } from './jsonView/index.js';
60
+ export type { JsonViewProps } from './jsonView/index.js';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * <JsonView>
3
+ *
4
+ * Read-only renderer for any JSON value bound to a JSON Schema (with
5
+ * optional Squisq UI hints). Designed to look like a polished settings
6
+ * summary or CRM record — not a disabled form. Themable via the
7
+ * standard Theme + SurfaceScheme props used elsewhere in Squisq.
8
+ */
9
+
10
+ import type { SquisqAnnotatedSchema } from '@bendyline/squisq/jsonForm';
11
+ import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
12
+ import { useJsonViewTokens } from './useJsonViewTokens';
13
+ import { RenderNode } from './RenderNode';
14
+
15
+ export interface JsonViewProps {
16
+ /** Schema describing the value's shape (with optional `squisq` UI hints). */
17
+ schema: SquisqAnnotatedSchema;
18
+ /** The value to display. */
19
+ value: unknown;
20
+ /** Optional theme. Defaults to `DEFAULT_THEME`. */
21
+ theme?: Theme;
22
+ /** Light/dark surface override; `'auto'` follows `prefers-color-scheme`. */
23
+ surface?: SurfaceScheme | 'auto';
24
+ /** Padding/gap density. Default: 'comfortable'. */
25
+ density?: 'comfortable' | 'compact';
26
+ /** Optional CSS class for the outer container. */
27
+ className?: string;
28
+ }
29
+
30
+ export function JsonView(props: JsonViewProps) {
31
+ const { schema, value, theme, surface, density = 'comfortable', className } = props;
32
+ const { style } = useJsonViewTokens(theme, surface);
33
+
34
+ const cls =
35
+ 'squisq-json-view' +
36
+ (density === 'compact' ? ' squisq-json-view--compact' : '') +
37
+ (className ? ` ${className}` : '');
38
+
39
+ return (
40
+ <div className={cls} style={style}>
41
+ <RenderNode
42
+ value={value}
43
+ schema={schema}
44
+ rootSchema={schema}
45
+ rootData={value}
46
+ pointer=""
47
+ density={density}
48
+ />
49
+ </div>
50
+ );
51
+ }