@bendyline/squisq-react 1.1.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.1.2",
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.2"
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,14 +333,25 @@ 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);
326
356
  // Intentionally no cleanup: if coverBlock's memoized reference changes
327
357
  // mid-grace (e.g., due to a preview re-render), clearing the timer would
@@ -344,7 +374,7 @@ export function DocPlayer({
344
374
  coverBlock &&
345
375
  (coverForced ||
346
376
  coverGraceActive ||
347
- (!isPlaying && currentTime === 0 && !renderMode && !autoPlay));
377
+ (!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
348
378
 
349
379
  // Auto-play if enabled (wait for audio to be ready)
350
380
  // Use a ref to track if we've already auto-played to avoid repeating on every render
@@ -785,7 +815,13 @@ export function DocPlayer({
785
815
  overflow: 'hidden',
786
816
  }}
787
817
  >
788
- <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
+ />
789
825
  </div>
790
826
  );
791
827
  }
@@ -857,7 +893,7 @@ export function DocPlayer({
857
893
  enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
858
894
  fontSize={16}
859
895
  captionStyle={activeCaptionStyle}
860
- theme={theme}
896
+ theme={effectiveTheme}
861
897
  viewport={activeViewport}
862
898
  />
863
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
  /**
@@ -294,18 +322,23 @@ export function LinearDocView({
294
322
  viewport,
295
323
  className,
296
324
  theme,
325
+ surface,
326
+ thinMargins = false,
327
+ imageDisplayMode = 'inline',
297
328
  }: LinearDocViewProps) {
298
329
  const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
299
330
  const totalBlocks = useMemo(() => countAll(doc.blocks), [doc.blocks]);
331
+ const autoSurface = useAutoSurface(surface === 'auto');
332
+ const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
300
333
 
301
- const renderContext: RenderContext = useMemo(
302
- () => ({
303
- theme: theme ?? DEFAULT_THEME,
334
+ const renderContext: RenderContext = useMemo(() => {
335
+ const baseTheme = theme ?? DEFAULT_THEME;
336
+ return {
337
+ theme: resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme,
304
338
  viewport: activeViewport,
305
339
  totalBlocks,
306
- }),
307
- [activeViewport, totalBlocks, theme],
308
- );
340
+ };
341
+ }, [activeViewport, totalBlocks, theme, resolvedSurface]);
309
342
 
310
343
  const activeTheme = renderContext.theme!;
311
344
  const bgColor = activeTheme.colors.background;
@@ -321,19 +354,28 @@ export function LinearDocView({
321
354
  className={`squisq-linear ${className || ''}`}
322
355
  style={{
323
356
  width: '100%',
324
- height: '100%',
325
- 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',
326
364
  overflowX: 'hidden',
327
365
  background: bgColor,
328
366
  }}
329
367
  >
330
368
  <div
331
- 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' : ''}`}
332
370
  style={
333
371
  {
334
- maxWidth: '720px',
335
- margin: '0 auto',
336
- 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',
337
379
  lineHeight: lineHt,
338
380
  fontSize: '16px',
339
381
  fontFamily: bodyFont,
@@ -414,6 +456,14 @@ export function LinearDocView({
414
456
  border-radius: 6px;
415
457
  margin: 0.5em 0;
416
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
+ }
417
467
  .squisq-linear-content strong {
418
468
  font-weight: 700;
419
469
  }
@@ -40,8 +40,25 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
40
40
  return nodes.map((node, i) => {
41
41
  const key = `${keyPrefix}i${i}`;
42
42
  switch (node.type) {
43
- case 'text':
44
- 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
+ }
45
62
 
46
63
  case 'emphasis':
47
64
  return (
@@ -138,6 +155,20 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
138
155
  </span>
139
156
  );
140
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
+
141
172
  default:
142
173
  return null;
143
174
  }
@@ -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';