@bendyline/squisq-react 1.1.1 → 1.2.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 (46) hide show
  1. package/dist/DocPlayer.d.ts +9 -2
  2. package/dist/DocPlayer.d.ts.map +1 -1
  3. package/dist/DocPlayer.js +33 -9
  4. package/dist/DocPlayer.js.map +1 -1
  5. package/dist/LinearDocView.d.ts +28 -2
  6. package/dist/LinearDocView.d.ts.map +1 -1
  7. package/dist/LinearDocView.js +46 -14
  8. package/dist/LinearDocView.js.map +1 -1
  9. package/dist/MarkdownRenderer.d.ts.map +1 -1
  10. package/dist/MarkdownRenderer.js +20 -3
  11. package/dist/MarkdownRenderer.js.map +1 -1
  12. package/dist/__tests__/LinearDocView.test.js +28 -0
  13. package/dist/__tests__/LinearDocView.test.js.map +1 -1
  14. package/dist/hooks/useAutoSurface.d.ts +11 -0
  15. package/dist/hooks/useAutoSurface.d.ts.map +1 -0
  16. package/dist/hooks/useAutoSurface.js +24 -0
  17. package/dist/hooks/useAutoSurface.js.map +1 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/layers/ImageLayer.js +6 -6
  22. package/dist/layers/ImageLayer.js.map +1 -1
  23. package/dist/layers/ShapeLayer.js +2 -2
  24. package/dist/layers/ShapeLayer.js.map +1 -1
  25. package/dist/layers/TableLayer.js +2 -2
  26. package/dist/layers/TableLayer.js.map +1 -1
  27. package/dist/layers/VideoLayer.js +2 -2
  28. package/dist/layers/VideoLayer.js.map +1 -1
  29. package/dist/squisq-player.global.js +24 -6
  30. package/dist/squisq-player.global.js.map +1 -1
  31. package/dist/standalone-entry.d.ts.map +1 -1
  32. package/dist/standalone-entry.js +14 -1
  33. package/dist/standalone-entry.js.map +1 -1
  34. package/dist/standalone-source.js +1 -1
  35. package/package.json +6 -6
  36. package/src/DocPlayer.tsx +51 -9
  37. package/src/LinearDocView.tsx +73 -15
  38. package/src/MarkdownRenderer.tsx +43 -9
  39. package/src/__tests__/LinearDocView.test.tsx +35 -0
  40. package/src/hooks/useAutoSurface.ts +33 -0
  41. package/src/index.ts +1 -0
  42. package/src/layers/ImageLayer.tsx +6 -6
  43. package/src/layers/ShapeLayer.tsx +2 -2
  44. package/src/layers/TableLayer.tsx +2 -2
  45. package/src/layers/VideoLayer.tsx +2 -2
  46. package/src/standalone-entry.tsx +13 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "React component library for doc playback, block rendering, and media layers",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -31,14 +31,14 @@
31
31
  ],
32
32
  "exports": {
33
33
  ".": {
34
- "import": "./dist/index.js",
35
- "types": "./dist/index.d.ts"
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
36
  },
37
37
  "./styles": "./src/styles/doc-animations.css",
38
38
  "./standalone": "./dist/squisq-player.global.js",
39
39
  "./standalone-source": {
40
- "import": "./dist/standalone-source.js",
41
- "types": "./dist/standalone-source.d.ts"
40
+ "types": "./dist/standalone-source.d.ts",
41
+ "import": "./dist/standalone-source.js"
42
42
  }
43
43
  },
44
44
  "scripts": {
@@ -52,7 +52,7 @@
52
52
  "react-dom": "^18.0.0 || ^19.0.0"
53
53
  },
54
54
  "dependencies": {
55
- "@bendyline/squisq": "1.2.1"
55
+ "@bendyline/squisq": "1.3.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "18.3.28",
package/src/DocPlayer.tsx CHANGED
@@ -25,9 +25,11 @@
25
25
  import { Fragment, useRef, useState, useEffect, useCallback, useMemo } from 'react';
26
26
  import type { Doc, Block, TextLayer, StartBlockConfig, DocBlock } from '@bendyline/squisq/schemas';
27
27
  import { isTemplateBlock, getCaptionAtTime } from '@bendyline/squisq/schemas';
28
- import type { Theme } from '@bendyline/squisq/schemas';
28
+ import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
29
+ import { applySurface } from '@bendyline/squisq/schemas';
29
30
  import { BlockRenderer } from './BlockRenderer';
30
31
  import { CaptionOverlay } from './CaptionOverlay';
32
+ import { useAutoSurface } from './hooks/useAutoSurface';
31
33
  import { useAudioSync } from './hooks/useAudioSync';
32
34
  import { useDocPlayback } from './hooks/useDocPlayback';
33
35
  import { useViewportOrientation } from './hooks/useViewportOrientation';
@@ -162,6 +164,13 @@ interface DocPlayerProps {
162
164
  forceViewport?: ViewportConfig;
163
165
  /** Theme to use for rendering (default: DEFAULT_THEME from the theme library) */
164
166
  theme?: Theme;
167
+ /**
168
+ * Optional surface scheme (light / dark paper) overlaid on top of the
169
+ * theme's colors. Passed through to the underlying LinearDocView when
170
+ * `displayMode === 'linear'`; otherwise overlaid onto the theme that
171
+ * renders the player's SVG blocks.
172
+ */
173
+ surface?: SurfaceScheme | 'auto';
165
174
  /**
166
175
  * Display mode for the player.
167
176
  * - `'video'` (default) — Traditional video playback with play/pause, scrub bar, auto-advance.
@@ -197,6 +206,7 @@ export function DocPlayer({
197
206
  forceViewport,
198
207
  displayMode = 'video',
199
208
  theme,
209
+ surface,
200
210
  captionStyle = 'standard',
201
211
  }: DocPlayerProps) {
202
212
  const isSlideshowMode = displayMode === 'slideshow';
@@ -275,6 +285,15 @@ export function DocPlayer({
275
285
  [renderMode, toggle, isPlaying, isSlideshowMode, isLinearMode],
276
286
  );
277
287
 
288
+ // Resolve surface (light/dark paper) and apply it to the theme before
289
+ // handing off to downstream renderers. Orthogonal to the editorial theme.
290
+ const autoSurface = useAutoSurface(surface === 'auto');
291
+ const resolvedSurface = surface === 'auto' ? autoSurface : surface;
292
+ const effectiveTheme = useMemo(() => {
293
+ const base = theme ?? DEFAULT_THEME;
294
+ return resolvedSurface ? applySurface(base, resolvedSurface) : base;
295
+ }, [theme, resolvedSurface]);
296
+
278
297
  // Doc playback hook - pass viewport for responsive template expansion
279
298
  const {
280
299
  currentBlock,
@@ -288,14 +307,14 @@ export function DocPlayer({
288
307
  nextBlock: _nextBlock,
289
308
  prevBlock: _prevBlock,
290
309
  blocks: expandedBlocks,
291
- } = useDocPlayback(script, currentTime, activeViewport, renderMode, theme);
310
+ } = useDocPlayback(script, currentTime, activeViewport, renderMode, effectiveTheme);
292
311
 
293
312
  // Expand cover block (startBlock) if present - uses active viewport
294
313
  const coverBlock = useMemo((): Block | null => {
295
314
  const startBlockConfig = script.startBlock as StartBlockConfig | undefined;
296
315
  if (!startBlockConfig) return null;
297
316
 
298
- const context = createTemplateContext(theme ?? DEFAULT_THEME, 0, 1, activeViewport);
317
+ const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
299
318
  const layers = expandCoverBlock(startBlockConfig, context);
300
319
 
301
320
  return {
@@ -305,7 +324,7 @@ export function DocPlayer({
305
324
  audioSegment: -1,
306
325
  layers,
307
326
  };
308
- }, [script.startBlock, activeViewport, theme]);
327
+ }, [script.startBlock, activeViewport, effectiveTheme]);
309
328
 
310
329
  // Render-mode cover block control: allows Playwright to force-show the cover block
311
330
  const [coverForced, setCoverForced] = useState(false);
@@ -314,20 +333,37 @@ export function DocPlayer({
314
333
  const [coverGraceActive, setCoverGraceActive] = useState(false);
315
334
  const coverGraceTimer = useRef<ReturnType<typeof setTimeout>>();
316
335
  const coverWasShowing = useRef(false);
336
+ // Track whether playback has ever been initiated — prevents the cover block
337
+ // from re-appearing when paused at currentTime === 0 (e.g., no audio source).
338
+ const hasPlayedOnce = useRef(false);
317
339
 
318
340
  // Track when cover is showing at rest (before play)
319
- const atRest = !!(coverBlock && !isPlaying && currentTime === 0 && !renderMode && !autoPlay);
341
+ const atRest = !!(
342
+ coverBlock &&
343
+ !isPlaying &&
344
+ currentTime === 0 &&
345
+ !hasPlayedOnce.current &&
346
+ !renderMode &&
347
+ !autoPlay
348
+ );
320
349
  if (atRest) coverWasShowing.current = true;
321
350
 
322
351
  useEffect(() => {
323
352
  if (isPlaying && coverWasShowing.current && coverBlock && !renderMode) {
324
353
  coverWasShowing.current = false;
354
+ hasPlayedOnce.current = true;
325
355
  setCoverGraceActive(true);
356
+ // Intentionally no cleanup: if coverBlock's memoized reference changes
357
+ // mid-grace (e.g., due to a preview re-render), clearing the timer would
358
+ // leave coverGraceActive stuck at true because the effect body won't
359
+ // re-run (coverWasShowing.current is now false).
326
360
  coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3000);
327
- return () => clearTimeout(coverGraceTimer.current);
328
361
  }
329
362
  }, [isPlaying, coverBlock, renderMode]);
330
363
 
364
+ // Always clear the grace timer on unmount
365
+ useEffect(() => () => clearTimeout(coverGraceTimer.current), []);
366
+
331
367
  // Determine if we should show the cover block
332
368
  // Show cover when: has cover block, not playing, at time 0, not in render mode
333
369
  // OR during the grace period after first play, OR when coverForced (render mode)
@@ -338,7 +374,7 @@ export function DocPlayer({
338
374
  coverBlock &&
339
375
  (coverForced ||
340
376
  coverGraceActive ||
341
- (!isPlaying && currentTime === 0 && !renderMode && !autoPlay));
377
+ (!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
342
378
 
343
379
  // Auto-play if enabled (wait for audio to be ready)
344
380
  // Use a ref to track if we've already auto-played to avoid repeating on every render
@@ -779,7 +815,13 @@ export function DocPlayer({
779
815
  overflow: 'hidden',
780
816
  }}
781
817
  >
782
- <LinearDocView doc={script} basePath={basePath} viewport={activeViewport} />
818
+ <LinearDocView
819
+ doc={script}
820
+ basePath={basePath}
821
+ viewport={activeViewport}
822
+ theme={theme}
823
+ surface={surface}
824
+ />
783
825
  </div>
784
826
  );
785
827
  }
@@ -851,7 +893,7 @@ export function DocPlayer({
851
893
  enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
852
894
  fontSize={16}
853
895
  captionStyle={activeCaptionStyle}
854
- theme={theme}
896
+ theme={effectiveTheme}
855
897
  viewport={activeViewport}
856
898
  />
857
899
  )}
@@ -18,9 +18,10 @@
18
18
  */
19
19
 
20
20
  import { useMemo } from 'react';
21
+ import { useAutoSurface } from './hooks/useAutoSurface';
21
22
  import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
22
23
  import type { ViewportConfig } from '@bendyline/squisq/schemas';
23
- import type { Theme } from '@bendyline/squisq/schemas';
24
+ import { applySurface, type SurfaceScheme, type Theme } from '@bendyline/squisq/schemas';
24
25
  import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
25
26
  import { getLayers, hasTemplate, DEFAULT_THEME } from '@bendyline/squisq/doc';
26
27
  import type { RenderContext } from '@bendyline/squisq/doc';
@@ -42,8 +43,35 @@ export interface LinearDocViewProps {
42
43
  className?: string;
43
44
  /** Theme to use for rendering (default: DEFAULT_THEME from the theme library) */
44
45
  theme?: Theme;
46
+ /**
47
+ * Optional surface scheme (light / dark paper) overlaid on top of the
48
+ * theme's colors. Orthogonal to `theme` — a theme picks editorial
49
+ * identity, a surface picks the paper. Pass `'auto'` to follow the
50
+ * user's OS `prefers-color-scheme`, a `SurfaceScheme` object to force a
51
+ * specific surface, or omit to use the theme's built-in colors.
52
+ */
53
+ surface?: SurfaceScheme | 'auto';
54
+ /**
55
+ * Use tight padding + a wider content column. The default layout is
56
+ * designed for a reading surface with breathing room (720px column,
57
+ * 24×16px padding). Short conversational snippets like chat replies
58
+ * benefit from a much tighter layout. Set to `true` to render with
59
+ * minimal padding and no max-width cap so the content hugs its
60
+ * container.
61
+ */
62
+ thinMargins?: boolean;
63
+ /**
64
+ * How images inside the doc should be sized. `'inline'` (default)
65
+ * flows them at natural size up to the column width; `'thumbnail'`
66
+ * constrains each image to a 100×100 box with aspect-preserving
67
+ * containment — use for chat history and other dense surfaces where
68
+ * full-size images would dominate the layout.
69
+ */
70
+ imageDisplayMode?: ImageDisplayMode;
45
71
  }
46
72
 
73
+ export type ImageDisplayMode = 'inline' | 'thumbnail';
74
+
47
75
  // ── Helpers ────────────────────────────────────────────────────────
48
76
 
49
77
  /**
@@ -257,8 +285,10 @@ function getTemplateDefaults(
257
285
  return { fact: headingText, explanation: bodyText || headingText };
258
286
  case 'comparisonBar':
259
287
  return { leftLabel: 'A', leftValue: 60, rightLabel: 'B', rightValue: 40 };
260
- case 'listBlock':
261
- return { items: extractListItems(contents) || ['Item 1', 'Item 2', 'Item 3'] };
288
+ case 'listBlock': {
289
+ const items = extractListItems(contents);
290
+ return { items: items.length > 0 ? items : ['Item 1', 'Item 2', 'Item 3'] };
291
+ }
262
292
  case 'definitionCard':
263
293
  return { term: headingText, definition: bodyText || headingText };
264
294
  case 'dateEvent':
@@ -292,18 +322,23 @@ export function LinearDocView({
292
322
  viewport,
293
323
  className,
294
324
  theme,
325
+ surface,
326
+ thinMargins = false,
327
+ imageDisplayMode = 'inline',
295
328
  }: LinearDocViewProps) {
296
329
  const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
297
330
  const totalBlocks = useMemo(() => countAll(doc.blocks), [doc.blocks]);
331
+ const autoSurface = useAutoSurface(surface === 'auto');
332
+ const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
298
333
 
299
- const renderContext: RenderContext = useMemo(
300
- () => ({
301
- theme: theme ?? DEFAULT_THEME,
334
+ const renderContext: RenderContext = useMemo(() => {
335
+ const baseTheme = theme ?? DEFAULT_THEME;
336
+ return {
337
+ theme: resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme,
302
338
  viewport: activeViewport,
303
339
  totalBlocks,
304
- }),
305
- [activeViewport, totalBlocks, theme],
306
- );
340
+ };
341
+ }, [activeViewport, totalBlocks, theme, resolvedSurface]);
307
342
 
308
343
  const activeTheme = renderContext.theme!;
309
344
  const bgColor = activeTheme.colors.background;
@@ -319,19 +354,28 @@ export function LinearDocView({
319
354
  className={`squisq-linear ${className || ''}`}
320
355
  style={{
321
356
  width: '100%',
322
- height: '100%',
323
- overflowY: 'auto',
357
+ // Thin-margins mode is the "embedded in someone else's container"
358
+ // signal (chat bubble, sidebar preview). Fit to content there so
359
+ // the host's bubble doesn't render a tall empty box when the doc
360
+ // is short. Standalone mode keeps height:100% for full-viewport
361
+ // scrolling.
362
+ height: thinMargins ? 'auto' : '100%',
363
+ overflowY: thinMargins ? 'visible' : 'auto',
324
364
  overflowX: 'hidden',
325
365
  background: bgColor,
326
366
  }}
327
367
  >
328
368
  <div
329
- className="squisq-linear-content squisq-md"
369
+ className={`squisq-linear-content squisq-md${thinMargins ? ' squisq-linear-content--thin' : ''}${imageDisplayMode === 'thumbnail' ? ' squisq-linear-content--thumbnail-images' : ''}`}
330
370
  style={
331
371
  {
332
- maxWidth: '720px',
333
- margin: '0 auto',
334
- padding: '24px 16px',
372
+ // Thin-margins mode drops the 720px reading column + generous
373
+ // page padding (right for standalone docs) in favor of a tight
374
+ // layout that hugs its container (right for chat bubbles and
375
+ // sidebar previews).
376
+ maxWidth: thinMargins ? 'none' : '720px',
377
+ margin: thinMargins ? '0' : '0 auto',
378
+ padding: thinMargins ? '0' : '24px 16px',
335
379
  lineHeight: lineHt,
336
380
  fontSize: '16px',
337
381
  fontFamily: bodyFont,
@@ -406,6 +450,20 @@ export function LinearDocView({
406
450
  border-top: 1px solid var(--squisq-linear-muted);
407
451
  margin: 1.5em 0;
408
452
  }
453
+ .squisq-linear-content img {
454
+ max-width: 100%;
455
+ height: auto;
456
+ border-radius: 6px;
457
+ margin: 0.5em 0;
458
+ }
459
+ .squisq-linear-content--thumbnail-images img {
460
+ max-width: 100px;
461
+ max-height: 100px;
462
+ width: auto;
463
+ height: auto;
464
+ object-fit: contain;
465
+ display: block;
466
+ }
409
467
  .squisq-linear-content strong {
410
468
  font-weight: 700;
411
469
  }
@@ -22,6 +22,7 @@ import type {
22
22
  MarkdownTableRow,
23
23
  MarkdownTableCell,
24
24
  } from '@bendyline/squisq/markdown';
25
+ import { useMediaUrl } from './hooks/MediaContext';
25
26
 
26
27
  // ── Props ──────────────────────────────────────────────────────────
27
28
 
@@ -39,8 +40,25 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
39
40
  return nodes.map((node, i) => {
40
41
  const key = `${keyPrefix}i${i}`;
41
42
  switch (node.type) {
42
- case 'text':
43
- return <Fragment key={key}>{node.value}</Fragment>;
43
+ case 'text': {
44
+ // Soft breaks (newlines without two trailing spaces) become \n in
45
+ // text nodes. HTML collapses \n to whitespace, which loses the visual
46
+ // line break the author wanted, so render <br> for each newline.
47
+ if (!node.value.includes('\n')) {
48
+ return <Fragment key={key}>{node.value}</Fragment>;
49
+ }
50
+ const parts = node.value.split('\n');
51
+ return (
52
+ <Fragment key={key}>
53
+ {parts.map((part, j) => (
54
+ <Fragment key={j}>
55
+ {j > 0 && <br />}
56
+ {part}
57
+ </Fragment>
58
+ ))}
59
+ </Fragment>
60
+ );
61
+ }
44
62
 
45
63
  case 'emphasis':
46
64
  return (
@@ -86,13 +104,7 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
86
104
 
87
105
  case 'image':
88
106
  return (
89
- <img
90
- key={key}
91
- className="squisq-md-image"
92
- src={node.url}
93
- alt={node.alt ?? ''}
94
- title={node.title ?? undefined}
95
- />
107
+ <MdImage key={key} src={node.url} alt={node.alt ?? ''} title={node.title ?? undefined} />
96
108
  );
97
109
 
98
110
  case 'break':
@@ -143,6 +155,20 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
143
155
  </span>
144
156
  );
145
157
 
158
+ case 'mention':
159
+ return (
160
+ <span
161
+ key={key}
162
+ className="squisq-md-mention mention"
163
+ data-mention="true"
164
+ data-kind={node.targetKind}
165
+ data-id={node.targetId}
166
+ data-label={node.displayName}
167
+ >
168
+ @{node.displayName}
169
+ </span>
170
+ );
171
+
146
172
  default:
147
173
  return null;
148
174
  }
@@ -343,6 +369,14 @@ function renderBlocks(nodes: MarkdownBlockNode[], keyPrefix = ''): React.ReactNo
343
369
  return nodes.map((node, i) => renderBlock(node, `${keyPrefix}b${i}`));
344
370
  }
345
371
 
372
+ // ── Image with MediaProvider resolution ───────────────────────────
373
+
374
+ /** Renders an <img> that resolves its src through the MediaProvider when available. */
375
+ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string }) {
376
+ const resolved = useMediaUrl(src, '.');
377
+ return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
378
+ }
379
+
346
380
  // ── Main Component ─────────────────────────────────────────────────
347
381
 
348
382
  /**
@@ -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,
@@ -177,4 +178,38 @@ describe('LinearDocView', () => {
177
178
  expect(container.querySelector('[data-block-id="alpha"]')).toBeTruthy();
178
179
  expect(container.querySelector('[data-block-id="beta"]')).toBeTruthy();
179
180
  });
181
+
182
+ it('uses the theme background by default', () => {
183
+ const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
184
+ const { container } = render(<LinearDocView doc={doc} />);
185
+ const el = container.querySelector('.squisq-linear') as HTMLElement;
186
+ expect(el.style.background).toBeTruthy();
187
+ // DEFAULT_THEME has a specific background; applying LIGHT_SURFACE below
188
+ // must produce a different value to prove override is working.
189
+ expect(el.style.background).not.toBe(LIGHT_SURFACE.background);
190
+ });
191
+
192
+ it('light surface overlays the theme background', () => {
193
+ const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
194
+ const { container } = render(
195
+ <LinearDocView doc={doc} theme={DEFAULT_THEME} surface={LIGHT_SURFACE} />,
196
+ );
197
+ const el = container.querySelector('.squisq-linear') as HTMLElement;
198
+ // React inline style background may round-trip as hex or rgb; compare the
199
+ // colour-normalized value by mounting a plain div with the expected.
200
+ const probe = document.createElement('div');
201
+ probe.style.background = LIGHT_SURFACE.background;
202
+ expect(el.style.background).toBe(probe.style.background);
203
+ });
204
+
205
+ it('dark surface overlays the theme background', () => {
206
+ const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
207
+ const { container } = render(
208
+ <LinearDocView doc={doc} theme={DEFAULT_THEME} surface={DARK_SURFACE} />,
209
+ );
210
+ const el = container.querySelector('.squisq-linear') as HTMLElement;
211
+ const probe = document.createElement('div');
212
+ probe.style.background = DARK_SURFACE.background;
213
+ expect(el.style.background).toBe(probe.style.background);
214
+ });
180
215
  });
@@ -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,7 @@ 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';
14
15
 
15
16
  // Layer components
16
17
  export { ImageLayer } from './layers/ImageLayer.js';
@@ -61,8 +61,8 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
61
61
  <foreignObject x={finalX} y={finalY} width={width} height={height}>
62
62
  <div
63
63
  style={{
64
- width: '100%',
65
- height: '100%',
64
+ width: `${width}px`,
65
+ height: `${height}px`,
66
66
  overflow: 'hidden',
67
67
  }}
68
68
  >
@@ -71,8 +71,8 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
71
71
  alt={content.alt || ''}
72
72
  className={kbStyle.className}
73
73
  style={{
74
- width: '100%',
75
- height: '100%',
74
+ width: `${width}px`,
75
+ height: `${height}px`,
76
76
  objectFit: 'cover',
77
77
  objectPosition: 'center',
78
78
  display: 'block',
@@ -101,8 +101,8 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
101
101
  src={src}
102
102
  alt={content.alt || ''}
103
103
  style={{
104
- width: '100%',
105
- height: '100%',
104
+ width: `${width}px`,
105
+ height: `${height}px`,
106
106
  objectFit: 'cover',
107
107
  objectPosition: 'center',
108
108
  display: 'block',
@@ -49,8 +49,8 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
49
49
  <foreignObject x={x} y={y} width={width} height={height}>
50
50
  <div
51
51
  style={{
52
- width: '100%',
53
- height: '100%',
52
+ width: `${width}px`,
53
+ height: `${height}px`,
54
54
  background: fill,
55
55
  borderRadius: content.borderRadius ? `${content.borderRadius}px` : undefined,
56
56
  pointerEvents: 'none',
@@ -51,8 +51,8 @@ export function TableLayer({ layer, viewport, blockTime }: TableLayerProps) {
51
51
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
52
  {...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}
53
53
  style={{
54
- width: '100%',
55
- height: '100%',
54
+ width: `${width}px`,
55
+ height: `${height}px`,
56
56
  display: 'flex',
57
57
  alignItems: 'center',
58
58
  justifyContent: 'center',
@@ -134,8 +134,8 @@ export function VideoLayer({
134
134
  data-clip-start={content.clipStart}
135
135
  data-clip-end={content.clipEnd}
136
136
  style={{
137
- width: '100%',
138
- height: '100%',
137
+ width: `${width}px`,
138
+ height: `${height}px`,
139
139
  objectFit: content.fit || 'cover',
140
140
  objectPosition: 'center',
141
141
  display: 'block',
@@ -86,10 +86,22 @@ function createInlineMediaProvider(
86
86
  images: Record<string, string>,
87
87
  basePath: string,
88
88
  ): MediaProvider {
89
+ // Doc image references may not exactly match image map keys
90
+ // (e.g., "images/hero.jpg" vs "hero.jpg"). Build a filename-keyed
91
+ // lookup so resolution can fall back to basename matching.
92
+ const byFilename: Record<string, string> = {};
93
+ for (const key of Object.keys(images)) {
94
+ const filename = key.split('/').pop()!;
95
+ byFilename[filename] = images[key];
96
+ }
97
+
89
98
  return {
90
99
  async resolveUrl(relativePath: string): Promise<string> {
91
100
  if (relativePath in images) return images[relativePath];
92
- // Fallback to basePath
101
+ const stripped = relativePath.replace(/^\.\//, '');
102
+ if (stripped !== relativePath && stripped in images) return images[stripped];
103
+ const filename = relativePath.split('/').pop()!;
104
+ if (filename in byFilename) return byFilename[filename];
93
105
  if (
94
106
  relativePath.startsWith('http') ||
95
107
  relativePath.startsWith('data:') ||