@bendyline/squisq-react 1.2.0 → 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 (77) hide show
  1. package/dist/InlineAudioPlayer.d.ts +15 -0
  2. package/dist/InlineAudioPlayer.d.ts.map +1 -0
  3. package/dist/InlineAudioPlayer.js +22 -0
  4. package/dist/InlineAudioPlayer.js.map +1 -0
  5. package/dist/InlineVideoPlayer.d.ts +21 -0
  6. package/dist/InlineVideoPlayer.d.ts.map +1 -0
  7. package/dist/InlineVideoPlayer.js +29 -0
  8. package/dist/InlineVideoPlayer.js.map +1 -0
  9. package/dist/LinearDocView.d.ts.map +1 -1
  10. package/dist/LinearDocView.js +124 -13
  11. package/dist/LinearDocView.js.map +1 -1
  12. package/dist/MarkdownRenderer.d.ts.map +1 -1
  13. package/dist/MarkdownRenderer.js +111 -2
  14. package/dist/MarkdownRenderer.js.map +1 -1
  15. package/dist/SocialCaptionOverlay.d.ts.map +1 -1
  16. package/dist/SocialCaptionOverlay.js +3 -2
  17. package/dist/SocialCaptionOverlay.js.map +1 -1
  18. package/dist/__tests__/JsonView.test.d.ts +2 -0
  19. package/dist/__tests__/JsonView.test.d.ts.map +1 -0
  20. package/dist/__tests__/JsonView.test.js +93 -0
  21. package/dist/__tests__/JsonView.test.js.map +1 -0
  22. package/dist/__tests__/LinearDocView.test.js +62 -0
  23. package/dist/__tests__/LinearDocView.test.js.map +1 -1
  24. package/dist/hooks/MediaContext.d.ts.map +1 -1
  25. package/dist/hooks/MediaContext.js +13 -7
  26. package/dist/hooks/MediaContext.js.map +1 -1
  27. package/dist/index.d.ts +7 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +5 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/jsonView/JsonView.d.ts +26 -0
  32. package/dist/jsonView/JsonView.d.ts.map +1 -0
  33. package/dist/jsonView/JsonView.js +12 -0
  34. package/dist/jsonView/JsonView.js.map +1 -0
  35. package/dist/jsonView/RenderNode.d.ts +22 -0
  36. package/dist/jsonView/RenderNode.d.ts.map +1 -0
  37. package/dist/jsonView/RenderNode.js +30 -0
  38. package/dist/jsonView/RenderNode.js.map +1 -0
  39. package/dist/jsonView/index.d.ts +3 -0
  40. package/dist/jsonView/index.d.ts.map +1 -0
  41. package/dist/jsonView/index.js +2 -0
  42. package/dist/jsonView/index.js.map +1 -0
  43. package/dist/jsonView/useJsonViewTokens.d.ts +14 -0
  44. package/dist/jsonView/useJsonViewTokens.d.ts.map +1 -0
  45. package/dist/jsonView/useJsonViewTokens.js +34 -0
  46. package/dist/jsonView/useJsonViewTokens.js.map +1 -0
  47. package/dist/jsonView/viewers.d.ts +30 -0
  48. package/dist/jsonView/viewers.d.ts.map +1 -0
  49. package/dist/jsonView/viewers.js +226 -0
  50. package/dist/jsonView/viewers.js.map +1 -0
  51. package/dist/squisq-player.css +1 -1
  52. package/dist/squisq-player.css.map +1 -1
  53. package/dist/squisq-player.global.js +24 -10
  54. package/dist/squisq-player.global.js.map +1 -1
  55. package/dist/standalone-source.js +1 -1
  56. package/dist/types.d.ts +5 -1
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/types.js.map +1 -1
  59. package/package.json +4 -3
  60. package/src/InlineAudioPlayer.tsx +46 -0
  61. package/src/InlineVideoPlayer.tsx +70 -0
  62. package/src/LinearDocView.tsx +136 -14
  63. package/src/MarkdownRenderer.tsx +156 -10
  64. package/src/SocialCaptionOverlay.tsx +3 -2
  65. package/src/__tests__/JsonView.test.tsx +111 -0
  66. package/src/__tests__/LinearDocView.test.tsx +66 -0
  67. package/src/hooks/MediaContext.tsx +15 -8
  68. package/src/index.ts +9 -0
  69. package/src/jsonView/JsonView.tsx +51 -0
  70. package/src/jsonView/RenderNode.tsx +51 -0
  71. package/src/jsonView/index.ts +2 -0
  72. package/src/jsonView/json-view.css +206 -0
  73. package/src/jsonView/useJsonViewTokens.ts +57 -0
  74. package/src/jsonView/viewers.tsx +343 -0
  75. package/src/styles/doc-animations.css +36 -0
  76. package/src/styles/index.css +7 -0
  77. package/src/types.ts +5 -1
@@ -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
+ });
@@ -85,6 +85,72 @@ describe('LinearDocView', () => {
85
85
  expect(container.textContent).toContain('Section body text');
86
86
  });
87
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
+
88
154
  it('renders annotated block as SVG card', () => {
89
155
  const doc = mkDoc([
90
156
  mkBlock({
@@ -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
package/src/index.ts CHANGED
@@ -12,6 +12,10 @@ export { DocProgressBar } from './DocProgressBar.js';
12
12
  export { MarkdownRenderer } from './MarkdownRenderer.js';
13
13
  export { LinearDocView } from './LinearDocView.js';
14
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';
15
19
 
16
20
  // Layer components
17
21
  export { ImageLayer } from './layers/ImageLayer.js';
@@ -26,6 +30,7 @@ export { useAudioSync } from './hooks/useAudioSync.js';
26
30
  export { useDocPlayback } from './hooks/useDocPlayback.js';
27
31
  export { useViewportOrientation } from './hooks/useViewportOrientation.js';
28
32
  export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
33
+ export { useAutoSurface } from './hooks/useAutoSurface.js';
29
34
 
30
35
  // Types
31
36
  export type { AudioProvider, AudioState, AudioActions } from './hooks/AudioProvider.js';
@@ -49,3 +54,7 @@ export { formatTime } from './types.js';
49
54
 
50
55
  // Utilities
51
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
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Recursive dispatcher: looks at a schema node + value, chooses a
3
+ * `ControlKind`, evaluates `hidden` rules, and renders the matching
4
+ * read-only viewer.
5
+ */
6
+
7
+ import {
8
+ chooseControl,
9
+ resolveFlag,
10
+ resolveRef,
11
+ type SquisqAnnotatedSchema,
12
+ } from '@bendyline/squisq/jsonForm';
13
+ import { VIEWERS, type ViewerProps } from './viewers';
14
+
15
+ export interface RenderNodeProps {
16
+ value: unknown;
17
+ schema: SquisqAnnotatedSchema;
18
+ rootSchema: SquisqAnnotatedSchema;
19
+ rootData: unknown;
20
+ pointer: string;
21
+ density: 'comfortable' | 'compact';
22
+ /**
23
+ * When a parent group already wrote its own card chrome (e.g. a
24
+ * card-stack item), suppress the nested group's outer title so the
25
+ * UI doesn't double up on labels.
26
+ */
27
+ suppressTopGroupTitle?: boolean;
28
+ }
29
+
30
+ export function RenderNode(props: RenderNodeProps): React.ReactElement | null {
31
+ const resolved = resolveRef(props.schema, props.rootSchema) ?? props.schema;
32
+
33
+ if (resolveFlag(resolved.squisq?.hidden, props.rootData)) return null;
34
+
35
+ const kind = chooseControl(resolved);
36
+ const Viewer = VIEWERS[kind];
37
+ const viewerProps: ViewerProps = {
38
+ value: props.value,
39
+ schema: resolved,
40
+ rootSchema: props.rootSchema,
41
+ rootData: props.rootData,
42
+ pointer: props.pointer,
43
+ density: props.density,
44
+ };
45
+ if (kind === 'group' || kind === 'card') {
46
+ // GroupViewer accepts an extra prop that RenderNode forwards.
47
+ const Group = Viewer as React.ComponentType<ViewerProps & { suppressTitle?: boolean }>;
48
+ return <Group {...viewerProps} suppressTitle={props.suppressTopGroupTitle} />;
49
+ }
50
+ return <Viewer {...viewerProps} />;
51
+ }
@@ -0,0 +1,2 @@
1
+ export { JsonView } from './JsonView';
2
+ export type { JsonViewProps } from './JsonView';
@@ -0,0 +1,206 @@
1
+ /**
2
+ * <JsonView> — read-only viewer styles.
3
+ *
4
+ * All colors flow through scoped CSS custom properties on the root
5
+ * element so the same component re-themes cleanly without re-render.
6
+ */
7
+
8
+ .squisq-json-view {
9
+ font-family: var(--squisq-json-body-font, system-ui, sans-serif);
10
+ color: var(--squisq-json-text);
11
+ background: var(--squisq-json-bg);
12
+ border-radius: var(--squisq-json-radius, 8px);
13
+ padding: 16px;
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 16px;
17
+ container-type: inline-size;
18
+ }
19
+
20
+ .squisq-json-view--compact {
21
+ padding: 8px;
22
+ gap: 8px;
23
+ }
24
+
25
+ .squisq-jv-group {
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: 12px;
29
+ background: color-mix(in srgb, var(--squisq-json-bg) 92%, var(--squisq-json-text) 8%);
30
+ border: 1px solid var(--squisq-json-border);
31
+ border-radius: var(--squisq-json-radius, 8px);
32
+ padding: 14px 16px;
33
+ }
34
+
35
+ .squisq-jv-group__title {
36
+ font-family: var(--squisq-json-title-font, inherit);
37
+ font-size: 1.05em;
38
+ font-weight: 600;
39
+ color: var(--squisq-json-text);
40
+ margin: 0;
41
+ }
42
+
43
+ .squisq-jv-group__help {
44
+ font-size: 0.85em;
45
+ color: var(--squisq-json-muted);
46
+ margin: -6px 0 0 0;
47
+ }
48
+
49
+ .squisq-jv-row {
50
+ display: grid;
51
+ grid-template-columns: minmax(110px, 0.4fr) minmax(0, 1fr);
52
+ gap: 12px;
53
+ align-items: baseline;
54
+ }
55
+
56
+ .squisq-json-view--compact .squisq-jv-row {
57
+ gap: 8px;
58
+ }
59
+
60
+ @container (max-width: 480px) {
61
+ .squisq-jv-row {
62
+ grid-template-columns: 1fr;
63
+ gap: 2px;
64
+ }
65
+ }
66
+
67
+ .squisq-jv-label {
68
+ color: var(--squisq-json-muted);
69
+ font-size: 0.85em;
70
+ text-transform: uppercase;
71
+ letter-spacing: 0.04em;
72
+ font-weight: 500;
73
+ }
74
+
75
+ .squisq-jv-value {
76
+ color: var(--squisq-json-text);
77
+ word-break: break-word;
78
+ }
79
+
80
+ .squisq-jv-value--multiline {
81
+ white-space: pre-wrap;
82
+ }
83
+
84
+ .squisq-jv-empty {
85
+ color: var(--squisq-json-muted);
86
+ font-style: italic;
87
+ }
88
+
89
+ .squisq-jv-chip-bin {
90
+ display: flex;
91
+ flex-wrap: wrap;
92
+ gap: 6px;
93
+ }
94
+
95
+ .squisq-jv-chip {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ padding: 3px 10px;
99
+ font-size: 0.85em;
100
+ background: color-mix(in srgb, var(--squisq-json-primary) 16%, transparent);
101
+ color: var(--squisq-json-text);
102
+ border-radius: 999px;
103
+ border: 1px solid color-mix(in srgb, var(--squisq-json-primary) 30%, transparent);
104
+ }
105
+
106
+ .squisq-jv-card-stack {
107
+ display: flex;
108
+ flex-direction: column;
109
+ gap: 10px;
110
+ }
111
+
112
+ .squisq-jv-card {
113
+ background: color-mix(in srgb, var(--squisq-json-bg) 95%, var(--squisq-json-text) 5%);
114
+ border: 1px solid var(--squisq-json-border);
115
+ border-radius: var(--squisq-json-radius, 6px);
116
+ padding: 12px 14px;
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: 8px;
120
+ }
121
+
122
+ .squisq-jv-card__title {
123
+ font-family: var(--squisq-json-title-font, inherit);
124
+ font-weight: 600;
125
+ font-size: 0.95em;
126
+ color: var(--squisq-json-text);
127
+ margin: 0;
128
+ }
129
+
130
+ .squisq-jv-color {
131
+ display: inline-flex;
132
+ align-items: center;
133
+ gap: 8px;
134
+ }
135
+
136
+ .squisq-jv-color__swatch {
137
+ width: 18px;
138
+ height: 18px;
139
+ border-radius: 4px;
140
+ border: 1px solid var(--squisq-json-border);
141
+ display: inline-block;
142
+ }
143
+
144
+ .squisq-jv-color__hex {
145
+ font-family: var(--squisq-json-mono-font, ui-monospace, Consolas, monospace);
146
+ font-size: 0.9em;
147
+ color: var(--squisq-json-muted);
148
+ }
149
+
150
+ .squisq-jv-toggle {
151
+ display: inline-flex;
152
+ align-items: center;
153
+ gap: 6px;
154
+ padding: 2px 10px;
155
+ border-radius: 999px;
156
+ font-size: 0.8em;
157
+ font-weight: 500;
158
+ }
159
+
160
+ .squisq-jv-toggle--on {
161
+ background: color-mix(in srgb, var(--squisq-json-primary) 18%, transparent);
162
+ color: var(--squisq-json-primary);
163
+ }
164
+
165
+ .squisq-jv-toggle--off {
166
+ background: color-mix(in srgb, var(--squisq-json-muted) 14%, transparent);
167
+ color: var(--squisq-json-muted);
168
+ }
169
+
170
+ .squisq-jv-tabs {
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: 8px;
174
+ }
175
+
176
+ .squisq-jv-tabs__discriminator {
177
+ font-size: 0.8em;
178
+ color: var(--squisq-json-muted);
179
+ }
180
+
181
+ .squisq-jv-richtext {
182
+ color: var(--squisq-json-text);
183
+ font-family: var(--squisq-json-body-font, inherit);
184
+ line-height: 1.55;
185
+ }
186
+
187
+ .squisq-jv-richtext h1,
188
+ .squisq-jv-richtext h2,
189
+ .squisq-jv-richtext h3,
190
+ .squisq-jv-richtext h4 {
191
+ font-family: var(--squisq-json-title-font, inherit);
192
+ margin: 0.6em 0 0.3em;
193
+ font-weight: 600;
194
+ }
195
+
196
+ .squisq-jv-richtext p {
197
+ margin: 0.4em 0;
198
+ }
199
+
200
+ .squisq-jv-richtext code {
201
+ font-family: var(--squisq-json-mono-font, ui-monospace, Consolas, monospace);
202
+ background: color-mix(in srgb, var(--squisq-json-text) 10%, transparent);
203
+ padding: 1px 4px;
204
+ border-radius: 3px;
205
+ font-size: 0.92em;
206
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Derive the CSS custom-property bag for `<JsonView>` from a Theme + Surface.
3
+ * Mirrors the pattern in LinearDocView so JsonView re-themes consistently
4
+ * with the rest of Squisq.
5
+ */
6
+
7
+ import { useMemo } from 'react';
8
+ import {
9
+ applySurface,
10
+ resolveFontFamily,
11
+ type SurfaceScheme,
12
+ type Theme,
13
+ } from '@bendyline/squisq/schemas';
14
+ import { DEFAULT_THEME } from '@bendyline/squisq/doc';
15
+ import { useAutoSurface } from '../hooks/useAutoSurface';
16
+
17
+ export interface JsonViewTokens {
18
+ /** Inline style object to spread onto the root element. */
19
+ style: React.CSSProperties;
20
+ /** The effective theme (after surface application). */
21
+ theme: Theme;
22
+ }
23
+
24
+ export function useJsonViewTokens(
25
+ theme: Theme | undefined,
26
+ surface: SurfaceScheme | 'auto' | undefined,
27
+ ): JsonViewTokens {
28
+ const auto = useAutoSurface(surface === 'auto');
29
+ const effectiveSurface = surface === 'auto' ? auto : (surface ?? undefined);
30
+
31
+ return useMemo(() => {
32
+ const baseTheme = theme ?? DEFAULT_THEME;
33
+ const finalTheme = effectiveSurface ? applySurface(baseTheme, effectiveSurface) : baseTheme;
34
+
35
+ const titleFont = resolveFontFamily(finalTheme.typography.titleFont, 'system-ui, sans-serif');
36
+ const bodyFont = resolveFontFamily(finalTheme.typography.bodyFont, 'system-ui, sans-serif');
37
+ const monoFont = resolveFontFamily(
38
+ finalTheme.typography.monoFont,
39
+ 'ui-monospace, Consolas, monospace',
40
+ );
41
+
42
+ const style: React.CSSProperties = {
43
+ ['--squisq-json-bg' as string]: finalTheme.colors.background,
44
+ ['--squisq-json-text' as string]: finalTheme.colors.text,
45
+ ['--squisq-json-muted' as string]: finalTheme.colors.textMuted,
46
+ ['--squisq-json-primary' as string]: finalTheme.colors.primary,
47
+ ['--squisq-json-accent' as string]: finalTheme.colors.secondary,
48
+ ['--squisq-json-border' as string]: `color-mix(in srgb, ${finalTheme.colors.textMuted} 35%, transparent)`,
49
+ ['--squisq-json-title-font' as string]: titleFont,
50
+ ['--squisq-json-body-font' as string]: bodyFont,
51
+ ['--squisq-json-mono-font' as string]: monoFont,
52
+ ['--squisq-json-radius' as string]: `${finalTheme.style.borderRadius ?? 8}px`,
53
+ };
54
+
55
+ return { style, theme: finalTheme };
56
+ }, [theme, effectiveSurface]);
57
+ }