@bendyline/squisq-editor-react 1.6.0 → 1.6.2

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 (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. package/src/tiptapBridge.ts +60 -33
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
6
+ import { EditorProvider, useEditorContext } from '../EditorContext';
7
+ import {
8
+ PreviewModeSwitch,
9
+ PreviewSettingsProvider,
10
+ PreviewToolbarControls,
11
+ usePreviewSettings,
12
+ } from '../PreviewControls';
13
+
14
+ function ModeProbe() {
15
+ const { activeDisplayMode } = usePreviewSettings();
16
+ return <div data-testid="active-mode">{activeDisplayMode}</div>;
17
+ }
18
+
19
+ function PreviewHarness() {
20
+ const { doc } = useEditorContext();
21
+ return (
22
+ <PreviewSettingsProvider doc={doc}>
23
+ <PreviewModeSwitch />
24
+ <ModeProbe />
25
+ </PreviewSettingsProvider>
26
+ );
27
+ }
28
+
29
+ function PreviewToolbarHarness() {
30
+ const { doc } = useEditorContext();
31
+ return (
32
+ <PreviewSettingsProvider doc={doc}>
33
+ <PreviewToolbarControls />
34
+ </PreviewSettingsProvider>
35
+ );
36
+ }
37
+
38
+ function renderPreviewControls(markdown: string) {
39
+ render(
40
+ <EditorProvider initialMarkdown={markdown}>
41
+ <PreviewHarness />
42
+ </EditorProvider>,
43
+ );
44
+ }
45
+
46
+ function renderPreviewToolbar(markdown: string) {
47
+ render(
48
+ <EditorProvider initialMarkdown={markdown}>
49
+ <PreviewToolbarHarness />
50
+ </EditorProvider>,
51
+ );
52
+ }
53
+
54
+ afterEach(() => cleanup());
55
+
56
+ describe('PreviewModeSwitch', () => {
57
+ it('labels the plain document preview as Document and the styled view as Page', () => {
58
+ renderPreviewControls('# Hello');
59
+
60
+ const labels = screen
61
+ .getAllByRole('button')
62
+ .map((button) => button.textContent)
63
+ .filter(Boolean);
64
+
65
+ expect(labels).toEqual(['Video', 'Slideshow', 'Page', 'Document']);
66
+
67
+ fireEvent.click(screen.getByRole('button', { name: 'Document' }));
68
+ expect(screen.getByTestId('active-mode').textContent).toBe('page');
69
+
70
+ fireEvent.click(screen.getByRole('button', { name: 'Page' }));
71
+ expect(screen.getByTestId('active-mode').textContent).toBe('linear');
72
+ });
73
+
74
+ it('maps product-facing display-mode frontmatter to the correct renderer values', async () => {
75
+ renderPreviewControls('---\ndisplay-mode: document\n---\n\n# Hello');
76
+
77
+ await waitFor(() => {
78
+ expect(screen.getByTestId('active-mode').textContent).toBe('page');
79
+ });
80
+ expect(screen.getByRole('button', { name: 'Document' }).getAttribute('aria-pressed')).toBe(
81
+ 'true',
82
+ );
83
+
84
+ cleanup();
85
+ renderPreviewControls('---\ndisplay-mode: page\n---\n\n# Hello');
86
+
87
+ await waitFor(() => {
88
+ expect(screen.getByTestId('active-mode').textContent).toBe('linear');
89
+ });
90
+ expect(screen.getByRole('button', { name: 'Page' }).getAttribute('aria-pressed')).toBe('true');
91
+ });
92
+ });
93
+
94
+ describe('PreviewToolbarControls', () => {
95
+ it('keeps the overflow popover inside the left viewport edge', async () => {
96
+ const originalResizeObserver = globalThis.ResizeObserver;
97
+ const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
98
+ const originalClientWidth = Object.getOwnPropertyDescriptor(
99
+ HTMLElement.prototype,
100
+ 'clientWidth',
101
+ );
102
+
103
+ class ResizeObserverStub implements ResizeObserver {
104
+ readonly callback: ResizeObserverCallback;
105
+
106
+ constructor(callback: ResizeObserverCallback) {
107
+ this.callback = callback;
108
+ }
109
+
110
+ observe() {
111
+ this.callback([], this);
112
+ }
113
+
114
+ unobserve() {}
115
+
116
+ disconnect() {}
117
+ }
118
+
119
+ globalThis.ResizeObserver = ResizeObserverStub;
120
+ HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
121
+ if (this.classList.contains('squisq-preview-controls-popover')) {
122
+ return new DOMRect(0, 0, 220, 240);
123
+ }
124
+ if (this.getAttribute('aria-label') === 'More preview settings') {
125
+ return new DOMRect(6, 20, 28, 28);
126
+ }
127
+ if (this.classList.contains('squisq-preview-control')) {
128
+ return new DOMRect(0, 0, 100, 24);
129
+ }
130
+ return new DOMRect(0, 0, 0, 0);
131
+ };
132
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
133
+ configurable: true,
134
+ get() {
135
+ return this.classList.contains('squisq-preview-controls') ? 40 : 0;
136
+ },
137
+ });
138
+
139
+ try {
140
+ renderPreviewToolbar('# Hello');
141
+
142
+ fireEvent.click(await screen.findByRole('button', { name: 'More preview settings' }));
143
+
144
+ await waitFor(() => {
145
+ const popover = document.querySelector<HTMLElement>('.squisq-preview-controls-popover');
146
+ expect(popover).not.toBeNull();
147
+ expect(popover?.style.left).toBe('8px');
148
+ });
149
+ } finally {
150
+ if (originalResizeObserver) {
151
+ globalThis.ResizeObserver = originalResizeObserver;
152
+ } else {
153
+ Reflect.deleteProperty(globalThis, 'ResizeObserver');
154
+ }
155
+ HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
156
+ if (originalClientWidth) {
157
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth);
158
+ } else {
159
+ Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth');
160
+ }
161
+ }
162
+ });
163
+ });
@@ -16,13 +16,23 @@ describe('Template annotation round-trip', () => {
16
16
  expect(back.trim()).toBe(original);
17
17
  });
18
18
 
19
+ it('round-trips param-only squiggly annotations', () => {
20
+ const original = '## Intro {[transition=fade]}';
21
+ const html = markdownToTiptap(original);
22
+ expect(html).not.toContain('data-template="transition=fade"');
23
+ expect(html).toContain('data-template-params="transition=fade"');
24
+ expect(tiptapToMarkdown(html).trim()).toBe(original);
25
+ });
26
+
19
27
  it('preserves template annotation through Tiptap-rendered HTML (with badge spans)', () => {
20
28
  // Simulates HTML that Tiptap actually renders after parse: includes the
21
29
  // squisq-heading-content + squisq-template-badge wrapper spans.
22
30
  const tiptapRendered =
23
31
  '<h2 data-template="comparisonBar">' +
24
32
  '<span class="squisq-heading-content">Getting Started</span>' +
25
- '<span class="squisq-template-badge" contenteditable="false" data-template="comparisonBar" data-template-label="Comparison Bar"></span>' +
33
+ '<span class="squisq-template-badge" contenteditable="false" data-template="comparisonBar">' +
34
+ '<span class="squisq-template-badge-core" data-template-label="Comparison Bar" aria-hidden="true"></span>' +
35
+ '</span>' +
26
36
  '</h2>';
27
37
  const md = tiptapToMarkdown(tiptapRendered);
28
38
  expect(md).toContain('## Getting Started');
@@ -39,7 +49,9 @@ describe('Template annotation round-trip', () => {
39
49
  const tiptapRendered =
40
50
  '<h2 data-block-attrs="transition=fade">' +
41
51
  '<span class="squisq-heading-content">Intro</span>' +
42
- '<span class="squisq-template-badge squisq-template-badge--empty" contenteditable="false"></span>' +
52
+ '<span class="squisq-template-badge squisq-template-badge--empty" contenteditable="false">' +
53
+ '<span class="squisq-template-badge-core" data-template-label="Block" aria-hidden="true"></span>' +
54
+ '</span>' +
43
55
  '<span class="squisq-props-badge" contenteditable="false" data-props-summary="Fade · 1:30 start"></span>' +
44
56
  '</h2>';
45
57
  const md = tiptapToMarkdown(tiptapRendered).trim();
@@ -49,4 +61,13 @@ describe('Template annotation round-trip', () => {
49
61
  // The CSS-painted summary lives in a data attribute; it must not bleed.
50
62
  expect(md).not.toContain('start');
51
63
  });
64
+
65
+ it('serializes data-template-params without data-template as a squiggly annotation', () => {
66
+ const tiptapRendered =
67
+ '<h2 data-template-params="transition=fade">' +
68
+ '<span class="squisq-heading-content">Intro</span>' +
69
+ '<span class="squisq-props-badge" contenteditable="false" data-props-summary="Fade"></span>' +
70
+ '</h2>';
71
+ expect(tiptapToMarkdown(tiptapRendered).trim()).toBe('## Intro {[transition=fade]}');
72
+ });
52
73
  });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DEFAULT_THEME, markdownToDoc } from '@bendyline/squisq/doc';
3
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
4
+ import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
5
+ import {
6
+ resolveTemplateContentPreview,
7
+ resolveTemplateContentPreviewResult,
8
+ type TemplatePreviewSource,
9
+ } from '../templateContentPreviewResolver';
10
+
11
+ function previewSource(markdown: string): TemplatePreviewSource {
12
+ const doc = markdownToDoc(parseMarkdown(markdown), { autoTemplates: false });
13
+ const block = doc.blocks[0];
14
+ if (!block) throw new Error('expected markdown to produce a block');
15
+ return {
16
+ block,
17
+ theme: DEFAULT_THEME,
18
+ viewport: VIEWPORT_PRESETS.landscape,
19
+ basePath: '/',
20
+ };
21
+ }
22
+
23
+ describe('template content previews', () => {
24
+ it('renders a candidate preview from the active block content', () => {
25
+ const visual = resolveTemplateContentPreview(
26
+ 'list',
27
+ previewSource(`## Launch Steps
28
+
29
+ - Draft the outline
30
+ - Review the visuals
31
+ - Publish the page
32
+ `),
33
+ );
34
+
35
+ expect(visual).toBeTruthy();
36
+ expect(JSON.stringify(visual?.layers)).toContain('Draft the outline');
37
+ });
38
+
39
+ it('falls back for content-specific templates when the block is too sparse', () => {
40
+ const visual = resolveTemplateContentPreview('list', previewSource('## About Squisq'));
41
+
42
+ expect(visual).toBeNull();
43
+ });
44
+
45
+ it('reports why stat and date previews cannot be derived', () => {
46
+ expect(
47
+ resolveTemplateContentPreviewResult('statHighlight', previewSource('## About Squisq')),
48
+ ).toMatchObject({
49
+ visual: null,
50
+ warning: 'No stat found in this block',
51
+ });
52
+
53
+ expect(
54
+ resolveTemplateContentPreviewResult('dateEvent', previewSource('## About Squisq')),
55
+ ).toMatchObject({
56
+ visual: null,
57
+ warning: 'No date found in this block',
58
+ });
59
+ });
60
+
61
+ it('reports why image previews cannot be derived', () => {
62
+ expect(
63
+ resolveTemplateContentPreviewResult('imageWithCaption', previewSource('## About Squisq')),
64
+ ).toMatchObject({
65
+ visual: null,
66
+ warning: 'No image found in this block',
67
+ });
68
+ });
69
+
70
+ it('reports why video previews cannot be derived without media', () => {
71
+ expect(
72
+ resolveTemplateContentPreviewResult('videoWithCaption', previewSource('## About Squisq')),
73
+ ).toMatchObject({
74
+ visual: null,
75
+ warning: 'No audio/video found in this block',
76
+ });
77
+
78
+ expect(
79
+ resolveTemplateContentPreviewResult('videoPullQuote', previewSource('## About Squisq')),
80
+ ).toMatchObject({
81
+ visual: null,
82
+ warning: 'No audio/video found in this block',
83
+ });
84
+ });
85
+
86
+ it('does not warn about missing media when an audio or video tag is present', () => {
87
+ expect(
88
+ resolveTemplateContentPreviewResult(
89
+ 'videoWithCaption',
90
+ previewSource('## Demo\n\n<video src="media/demo.mp4" controls></video>'),
91
+ ).warning,
92
+ ).toBeUndefined();
93
+
94
+ expect(
95
+ resolveTemplateContentPreviewResult(
96
+ 'videoPullQuote',
97
+ previewSource('## Narration\n\n<audio src="audio/narration.webm" controls></audio>'),
98
+ ).warning,
99
+ ).toBeUndefined();
100
+ });
101
+ });
@@ -17,6 +17,22 @@ describe('markdownToTiptap', () => {
17
17
  expect(html).toContain('Hello world');
18
18
  });
19
19
 
20
+ it('preserves extra blank lines as empty paragraphs', () => {
21
+ expect(markdownToTiptap('Alpha\n\nBeta')).toBe('<p>Alpha</p><p>Beta</p>');
22
+ expect(markdownToTiptap('Alpha\n\n\nBeta')).toBe('<p>Alpha</p><p></p><p>Beta</p>');
23
+ expect(markdownToTiptap('Alpha\n\n\n\nBeta')).toBe('<p>Alpha</p><p></p><p></p><p>Beta</p>');
24
+ });
25
+
26
+ it('preserves trailing extra blank lines as empty paragraphs', () => {
27
+ expect(markdownToTiptap('Alpha\n')).toBe('<p>Alpha</p>');
28
+ expect(markdownToTiptap('Alpha\n\n')).toBe('<p>Alpha</p><p></p>');
29
+ });
30
+
31
+ it('preserves leading paragraph spaces as visible HTML whitespace', () => {
32
+ expect(markdownToTiptap(' indented')).toBe('<p>&nbsp;&nbsp;indented</p>');
33
+ expect(markdownToTiptap('Alpha\n\n Beta')).toBe('<p>Alpha</p><p>&nbsp;&nbsp;&nbsp;Beta</p>');
34
+ });
35
+
20
36
  it('converts headings h1-h3', () => {
21
37
  expect(markdownToTiptap('# Title')).toContain('<h1');
22
38
  expect(markdownToTiptap('## Subtitle')).toContain('<h2');
@@ -193,6 +209,22 @@ describe('tiptapToMarkdown', () => {
193
209
  expect(md).toContain('Hello world');
194
210
  });
195
211
 
212
+ it('preserves empty paragraphs as extra blank lines', () => {
213
+ expect(tiptapToMarkdown('<p>Alpha</p><p>Beta</p>')).toBe('Alpha\n\nBeta\n');
214
+ expect(tiptapToMarkdown('<p>Alpha</p><p></p><p>Beta</p>')).toBe('Alpha\n\n\nBeta\n');
215
+ expect(tiptapToMarkdown('<p>Alpha</p><p></p><p></p><p>Beta</p>')).toBe('Alpha\n\n\n\nBeta\n');
216
+ });
217
+
218
+ it('preserves trailing empty paragraphs', () => {
219
+ expect(tiptapToMarkdown('<p>Alpha</p><p></p>')).toBe('Alpha\n\n');
220
+ });
221
+
222
+ it('restores leading paragraph spaces from visible HTML whitespace', () => {
223
+ expect(tiptapToMarkdown('<p>&nbsp;&nbsp;indented</p>')).toBe(' indented\n');
224
+ expect(tiptapToMarkdown('<p>\u00a0\u00a0indented</p>')).toBe(' indented\n');
225
+ expect(tiptapToMarkdown('<p>&#160;&#xA0;indented</p>')).toBe(' indented\n');
226
+ });
227
+
196
228
  it('converts headings', () => {
197
229
  expect(tiptapToMarkdown('<h1>Title</h1>')).toContain('# Title');
198
230
  expect(tiptapToMarkdown('<h2>Sub</h2>')).toContain('## Sub');
@@ -430,6 +462,21 @@ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
430
462
  expect(result).not.toContain('- [x] buy milk');
431
463
  });
432
464
 
465
+ it('preserves extra blank lines between paragraphs exactly', () => {
466
+ expect(roundTrip('Alpha\n\nBeta\n')).toBe('Alpha\n\nBeta\n');
467
+ expect(roundTrip('Alpha\n\n\nBeta\n')).toBe('Alpha\n\n\nBeta\n');
468
+ expect(roundTrip('Alpha\n\n\n\nBeta\n')).toBe('Alpha\n\n\n\nBeta\n');
469
+ });
470
+
471
+ it('preserves trailing empty paragraphs exactly', () => {
472
+ expect(roundTrip('Alpha\n\n')).toBe('Alpha\n\n');
473
+ });
474
+
475
+ it('preserves leading paragraph spaces exactly', () => {
476
+ expect(roundTrip(' indented\n')).toBe(' indented\n');
477
+ expect(roundTrip('Alpha\n\n Beta\n')).toBe('Alpha\n\n Beta\n');
478
+ });
479
+
433
480
  it('preserves quoted template params with spaces', () => {
434
481
  const result = roundTrip(
435
482
  '## Gallery {[imageWithCaption src=photo.jpg caption="Beach at sunset"]}',
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { DARK_SURFACE, LIGHT_SURFACE, DEFAULT_THEME } from '@bendyline/squisq/schemas';
4
+ import { useJsonEditorTokens } from '../jsonEditor/useJsonEditorTokens';
5
+
6
+ type StyleBag = Record<string, string>;
7
+
8
+ /** Install a matchMedia stub whose `(prefers-color-scheme: dark)` resolves to `dark`. */
9
+ function mockPrefersDark(dark: boolean): void {
10
+ Object.defineProperty(window, 'matchMedia', {
11
+ configurable: true,
12
+ value: (query: string) => ({
13
+ matches: dark && query.includes('dark'),
14
+ media: query,
15
+ onchange: null,
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ addEventListener: vi.fn(),
19
+ removeEventListener: vi.fn(),
20
+ dispatchEvent: vi.fn(() => false),
21
+ }),
22
+ });
23
+ }
24
+
25
+ describe('useJsonEditorTokens', () => {
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ it('emits jsonform-prefixed tokens', () => {
31
+ mockPrefersDark(false);
32
+ const { result } = renderHook(() => useJsonEditorTokens(DEFAULT_THEME, LIGHT_SURFACE));
33
+ const style = result.current.style as StyleBag;
34
+ expect(style['--squisq-jsonform-bg']).toBe(LIGHT_SURFACE.background);
35
+ expect(style).toHaveProperty('--squisq-jsonform-warning');
36
+ expect(style).toHaveProperty('--squisq-jsonform-input-bg');
37
+ });
38
+
39
+ it("responds to a dark OS preference under surface='auto' (reactive via useAutoSurface)", () => {
40
+ mockPrefersDark(true);
41
+ const { result } = renderHook(() => useJsonEditorTokens(DEFAULT_THEME, 'auto'));
42
+ const style = result.current.style as StyleBag;
43
+ expect(style['--squisq-jsonform-bg']).toBe(DARK_SURFACE.background);
44
+ expect(style['--squisq-jsonform-text']).toBe(DARK_SURFACE.text);
45
+ expect(result.current.theme.colors.background).toBe(DARK_SURFACE.background);
46
+ });
47
+
48
+ it("uses light surface under surface='auto' when the OS prefers light", () => {
49
+ mockPrefersDark(false);
50
+ const { result } = renderHook(() => useJsonEditorTokens(DEFAULT_THEME, 'auto'));
51
+ const style = result.current.style as StyleBag;
52
+ expect(style['--squisq-jsonform-bg']).toBe(LIGHT_SURFACE.background);
53
+ });
54
+
55
+ beforeEach(() => {
56
+ // Default to a defined matchMedia so the hook's useAutoSurface can subscribe.
57
+ mockPrefersDark(false);
58
+ });
59
+ });
@@ -165,6 +165,23 @@ describe('useMediaRecorder lifecycle', () => {
165
165
  expect(result.current.stream).not.toBeNull();
166
166
  });
167
167
 
168
+ it('defaults to the mic source when called with no options', async () => {
169
+ const { result } = renderHook(() => useMediaRecorder());
170
+
171
+ expect(result.current.state).toBe('idle');
172
+
173
+ await act(async () => {
174
+ await result.current.request();
175
+ });
176
+
177
+ // Mic path: audio-only capture via getUserMedia, lands in `audio/`.
178
+ expect(result.current.state).toBe('ready');
179
+ expect(result.current.mimeType).toMatch(/^audio\/webm/);
180
+ expect(result.current.directory).toBe('audio');
181
+ const getUserMedia = navigator.mediaDevices.getUserMedia as ReturnType<typeof vi.fn>;
182
+ expect(getUserMedia).toHaveBeenCalled();
183
+ });
184
+
168
185
  it('camera includes the mic by default', async () => {
169
186
  const { result } = renderHook(() => useMediaRecorder({ source: 'camera' }));
170
187
  await act(async () => {
@@ -0,0 +1,124 @@
1
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
2
+ import { MarkdownRenderer } from '@bendyline/squisq-react';
3
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
4
+ import type { CodeContext, CodeContextSection } from './types';
5
+
6
+ /**
7
+ * One context section rendered inside a Monaco view zone: a single-line
8
+ * disclosure strip, plus the full markdown body while expanded. The body is
9
+ * rendered lazily — a file with 100 collapsed sections parses 100 one-liners,
10
+ * nothing more.
11
+ */
12
+ export interface CodeContextSectionViewProps {
13
+ section: Omit<CodeContextSection, 'line'>;
14
+ expanded: boolean;
15
+ onToggle: (id: string) => void;
16
+ linkSchemes?: readonly string[] | undefined;
17
+ onLinkClick?: CodeContext['onLinkClick'] | undefined;
18
+ /** Native `#L<n>` handling: reveal that line in the editor. */
19
+ onRevealLine: (line: number) => void;
20
+ /** Reports the rendered content height so the zone can be resized to fit. */
21
+ onMeasure: (id: string, px: number) => void;
22
+ }
23
+
24
+ export function CodeContextSectionView({
25
+ section,
26
+ expanded,
27
+ onToggle,
28
+ linkSchemes,
29
+ onLinkClick,
30
+ onRevealLine,
31
+ onMeasure,
32
+ }: CodeContextSectionViewProps) {
33
+ const rootRef = useRef<HTMLDivElement | null>(null);
34
+
35
+ const stripNodes = useMemo(
36
+ () => parseMarkdown(section.summaryMarkdown).children,
37
+ [section.summaryMarkdown],
38
+ );
39
+ const bodyNodes = useMemo(
40
+ () => (expanded && section.markdown ? parseMarkdown(section.markdown).children : null),
41
+ [expanded, section.markdown],
42
+ );
43
+
44
+ // Height feedback loop: report the content's real height whenever it
45
+ // changes. Monaco display:none's offscreen zones and ResizeObserver reports
46
+ // 0×0 for those — ignore zeros so scrolled-away zones keep their height.
47
+ useEffect(() => {
48
+ const el = rootRef.current;
49
+ if (!el || typeof ResizeObserver === 'undefined') return;
50
+ const report = () => {
51
+ const h = el.offsetHeight;
52
+ if (h > 0) onMeasure(section.id, h);
53
+ };
54
+ report();
55
+ const ro = new ResizeObserver(report);
56
+ ro.observe(el);
57
+ return () => ro.disconnect();
58
+ }, [section.id, onMeasure]);
59
+
60
+ // Delegated link interception. `#L<n>` reveals natively; everything else
61
+ // goes to the host callback (returning false opts back into default
62
+ // navigation).
63
+ const handleClick = useCallback(
64
+ (e: React.MouseEvent) => {
65
+ const anchor = (e.target as HTMLElement).closest?.('a');
66
+ if (!anchor) return;
67
+ const href = anchor.getAttribute('href') ?? '';
68
+ const lineMatch = /^#L(\d+)$/.exec(href);
69
+ if (lineMatch) {
70
+ e.preventDefault();
71
+ e.stopPropagation();
72
+ onRevealLine(Number(lineMatch[1]));
73
+ return;
74
+ }
75
+ if (onLinkClick) {
76
+ const handled = onLinkClick(href, { sectionId: section.id });
77
+ if (handled !== false) {
78
+ e.preventDefault();
79
+ e.stopPropagation();
80
+ }
81
+ }
82
+ },
83
+ [onLinkClick, onRevealLine, section.id],
84
+ );
85
+
86
+ // Keep Monaco's container-level mousedown handler from hijacking clicks
87
+ // and text selection inside the section.
88
+ const stopMouseDown = useCallback((e: React.MouseEvent) => {
89
+ e.stopPropagation();
90
+ }, []);
91
+
92
+ return (
93
+ // Click handler is delegated anchor interception only; keyboard users
94
+ // reach links natively and the strip itself is a real <button>.
95
+ <div
96
+ ref={rootRef}
97
+ className={`squisq-ccx-section${expanded ? ' squisq-ccx-section--expanded' : ''}`}
98
+ onClick={handleClick}
99
+ onMouseDown={stopMouseDown}
100
+ >
101
+ <button
102
+ type="button"
103
+ className="squisq-ccx-strip"
104
+ aria-expanded={expanded}
105
+ onClick={() => onToggle(section.id)}
106
+ >
107
+ <span className="squisq-ccx-chevron" aria-hidden="true">
108
+ {expanded ? '▾' : '▸'}
109
+ </span>
110
+ <span className="squisq-ccx-strip-text">
111
+ <MarkdownRenderer nodes={stripNodes} {...(linkSchemes ? { linkSchemes } : {})} />
112
+ </span>
113
+ </button>
114
+ {expanded &&
115
+ (bodyNodes ? (
116
+ <div className="squisq-ccx-body">
117
+ <MarkdownRenderer nodes={bodyNodes} {...(linkSchemes ? { linkSchemes } : {})} />
118
+ </div>
119
+ ) : (
120
+ <div className="squisq-ccx-body squisq-ccx-body--loading">Loading…</div>
121
+ ))}
122
+ </div>
123
+ );
124
+ }