@bendyline/squisq-react 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/DocPlayer.d.ts +9 -2
  2. package/dist/DocPlayer.d.ts.map +1 -1
  3. package/dist/DocPlayer.js +27 -8
  4. package/dist/DocPlayer.js.map +1 -1
  5. package/dist/InlineAudioPlayer.d.ts +15 -0
  6. package/dist/InlineAudioPlayer.d.ts.map +1 -0
  7. package/dist/InlineAudioPlayer.js +22 -0
  8. package/dist/InlineAudioPlayer.js.map +1 -0
  9. package/dist/InlineVideoPlayer.d.ts +21 -0
  10. package/dist/InlineVideoPlayer.d.ts.map +1 -0
  11. package/dist/InlineVideoPlayer.js +29 -0
  12. package/dist/InlineVideoPlayer.js.map +1 -0
  13. package/dist/LinearDocView.d.ts +28 -2
  14. package/dist/LinearDocView.d.ts.map +1 -1
  15. package/dist/LinearDocView.js +159 -24
  16. package/dist/LinearDocView.js.map +1 -1
  17. package/dist/MarkdownRenderer.d.ts.map +1 -1
  18. package/dist/MarkdownRenderer.js +123 -4
  19. package/dist/MarkdownRenderer.js.map +1 -1
  20. package/dist/SocialCaptionOverlay.d.ts.map +1 -1
  21. package/dist/SocialCaptionOverlay.js +3 -2
  22. package/dist/SocialCaptionOverlay.js.map +1 -1
  23. package/dist/__tests__/JsonView.test.d.ts +2 -0
  24. package/dist/__tests__/JsonView.test.d.ts.map +1 -0
  25. package/dist/__tests__/JsonView.test.js +93 -0
  26. package/dist/__tests__/JsonView.test.js.map +1 -0
  27. package/dist/__tests__/LinearDocView.test.js +90 -0
  28. package/dist/__tests__/LinearDocView.test.js.map +1 -1
  29. package/dist/hooks/MediaContext.d.ts.map +1 -1
  30. package/dist/hooks/MediaContext.js +13 -7
  31. package/dist/hooks/MediaContext.js.map +1 -1
  32. package/dist/hooks/useAutoSurface.d.ts +11 -0
  33. package/dist/hooks/useAutoSurface.d.ts.map +1 -0
  34. package/dist/hooks/useAutoSurface.js +24 -0
  35. package/dist/hooks/useAutoSurface.js.map +1 -0
  36. package/dist/index.d.ts +8 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/jsonView/JsonView.d.ts +26 -0
  41. package/dist/jsonView/JsonView.d.ts.map +1 -0
  42. package/dist/jsonView/JsonView.js +12 -0
  43. package/dist/jsonView/JsonView.js.map +1 -0
  44. package/dist/jsonView/RenderNode.d.ts +22 -0
  45. package/dist/jsonView/RenderNode.d.ts.map +1 -0
  46. package/dist/jsonView/RenderNode.js +30 -0
  47. package/dist/jsonView/RenderNode.js.map +1 -0
  48. package/dist/jsonView/index.d.ts +3 -0
  49. package/dist/jsonView/index.d.ts.map +1 -0
  50. package/dist/jsonView/index.js +2 -0
  51. package/dist/jsonView/index.js.map +1 -0
  52. package/dist/jsonView/useJsonViewTokens.d.ts +14 -0
  53. package/dist/jsonView/useJsonViewTokens.d.ts.map +1 -0
  54. package/dist/jsonView/useJsonViewTokens.js +34 -0
  55. package/dist/jsonView/useJsonViewTokens.js.map +1 -0
  56. package/dist/jsonView/viewers.d.ts +30 -0
  57. package/dist/jsonView/viewers.d.ts.map +1 -0
  58. package/dist/jsonView/viewers.js +226 -0
  59. package/dist/jsonView/viewers.js.map +1 -0
  60. package/dist/squisq-player.css +1 -1
  61. package/dist/squisq-player.css.map +1 -1
  62. package/dist/squisq-player.global.js +32 -8
  63. package/dist/squisq-player.global.js.map +1 -1
  64. package/dist/standalone-source.js +1 -1
  65. package/dist/types.d.ts +5 -1
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/package.json +8 -7
  69. package/src/DocPlayer.tsx +44 -8
  70. package/src/InlineAudioPlayer.tsx +46 -0
  71. package/src/InlineVideoPlayer.tsx +70 -0
  72. package/src/LinearDocView.tsx +198 -26
  73. package/src/MarkdownRenderer.tsx +189 -12
  74. package/src/SocialCaptionOverlay.tsx +3 -2
  75. package/src/__tests__/JsonView.test.tsx +111 -0
  76. package/src/__tests__/LinearDocView.test.tsx +101 -0
  77. package/src/hooks/MediaContext.tsx +15 -8
  78. package/src/hooks/useAutoSurface.ts +33 -0
  79. package/src/index.ts +10 -0
  80. package/src/jsonView/JsonView.tsx +51 -0
  81. package/src/jsonView/RenderNode.tsx +51 -0
  82. package/src/jsonView/index.ts +2 -0
  83. package/src/jsonView/json-view.css +206 -0
  84. package/src/jsonView/useJsonViewTokens.ts +57 -0
  85. package/src/jsonView/viewers.tsx +343 -0
  86. package/src/styles/doc-animations.css +36 -0
  87. package/src/styles/index.css +7 -0
  88. package/src/types.ts +5 -1
@@ -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
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Read-only viewer renderers for `<JsonView>`. Each viewer is a small
3
+ * pure function that takes a value + schema slice and renders inline.
4
+ * Group / card-stack viewers recurse into `RenderNode`.
5
+ */
6
+
7
+ import { Fragment, useMemo } from 'react';
8
+ import {
9
+ arrayItemKind,
10
+ type ControlKind,
11
+ type SquisqAnnotatedSchema,
12
+ } from '@bendyline/squisq/jsonForm';
13
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
14
+ import { MarkdownRenderer } from '../MarkdownRenderer';
15
+ import { RenderNode } from './RenderNode';
16
+
17
+ export interface ViewerProps {
18
+ value: unknown;
19
+ schema: SquisqAnnotatedSchema;
20
+ rootSchema: SquisqAnnotatedSchema;
21
+ rootData: unknown;
22
+ pointer: string;
23
+ density: 'comfortable' | 'compact';
24
+ }
25
+
26
+ // ── Primitive viewers ──────────────────────────────────────────────
27
+
28
+ export function TextViewer({ value }: ViewerProps) {
29
+ if (value === undefined || value === null || value === '') {
30
+ return <span className="squisq-jv-empty">—</span>;
31
+ }
32
+ return <span className="squisq-jv-value">{String(value)}</span>;
33
+ }
34
+
35
+ export function MultilineViewer({ value }: ViewerProps) {
36
+ if (value === undefined || value === null || value === '') {
37
+ return <span className="squisq-jv-empty">—</span>;
38
+ }
39
+ return <span className="squisq-jv-value squisq-jv-value--multiline">{String(value)}</span>;
40
+ }
41
+
42
+ export function RichTextViewer({ value }: ViewerProps) {
43
+ const nodes = useMemo(() => {
44
+ if (typeof value !== 'string' || value === '') return null;
45
+ try {
46
+ const doc = parseMarkdown(value);
47
+ return doc.children;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }, [value]);
52
+ if (!nodes) return <span className="squisq-jv-empty">—</span>;
53
+ return (
54
+ <div className="squisq-jv-richtext">
55
+ <MarkdownRenderer nodes={nodes} />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ export function NumberViewer({ value }: ViewerProps) {
61
+ if (value === undefined || value === null) {
62
+ return <span className="squisq-jv-empty">—</span>;
63
+ }
64
+ return <span className="squisq-jv-value">{String(value)}</span>;
65
+ }
66
+
67
+ export function BooleanViewer({ value }: ViewerProps) {
68
+ const on = Boolean(value);
69
+ return (
70
+ <span className={`squisq-jv-toggle squisq-jv-toggle--${on ? 'on' : 'off'}`}>
71
+ {on ? 'On' : 'Off'}
72
+ </span>
73
+ );
74
+ }
75
+
76
+ export function EnumViewer({ value, schema }: ViewerProps) {
77
+ if (value === undefined || value === null || value === '') {
78
+ return <span className="squisq-jv-empty">—</span>;
79
+ }
80
+ const labels = schema.squisq?.enumLabels;
81
+ const display = labels && typeof value === 'string' ? (labels[value] ?? value) : String(value);
82
+ return <span className="squisq-jv-value">{display}</span>;
83
+ }
84
+
85
+ export function ColorViewer({ value }: ViewerProps) {
86
+ if (typeof value !== 'string' || value === '') {
87
+ return <span className="squisq-jv-empty">—</span>;
88
+ }
89
+ return (
90
+ <span className="squisq-jv-color">
91
+ <span className="squisq-jv-color__swatch" style={{ background: value }} aria-hidden />
92
+ <span className="squisq-jv-color__hex">{value}</span>
93
+ </span>
94
+ );
95
+ }
96
+
97
+ export function DateViewer({ value, schema }: ViewerProps) {
98
+ if (typeof value !== 'string' || value === '') {
99
+ return <span className="squisq-jv-empty">—</span>;
100
+ }
101
+ const fmt = schema.format;
102
+ let display = value;
103
+ try {
104
+ const date = new Date(value);
105
+ if (!Number.isNaN(date.getTime())) {
106
+ if (fmt === 'time') {
107
+ display = date.toLocaleTimeString();
108
+ } else if (fmt === 'date') {
109
+ display = date.toLocaleDateString();
110
+ } else {
111
+ display = date.toLocaleString();
112
+ }
113
+ }
114
+ } catch {
115
+ /* fall through to raw value */
116
+ }
117
+ return <span className="squisq-jv-value">{display}</span>;
118
+ }
119
+
120
+ // ── Composite viewers ─────────────────────────────────────────────
121
+
122
+ export function ChipBinViewer({ value, schema }: ViewerProps) {
123
+ if (!Array.isArray(value) || value.length === 0) {
124
+ return <span className="squisq-jv-empty">—</span>;
125
+ }
126
+ const itemSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
127
+ const labels = itemSchema?.squisq?.enumLabels;
128
+ return (
129
+ <div className="squisq-jv-chip-bin">
130
+ {value.map((item, i) => {
131
+ const label =
132
+ labels && typeof item === 'string' ? (labels[item] ?? String(item)) : String(item);
133
+ return (
134
+ <span key={i} className="squisq-jv-chip">
135
+ {label}
136
+ </span>
137
+ );
138
+ })}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ export function CardStackViewer(props: ViewerProps) {
144
+ const { value, schema, rootSchema, rootData, pointer, density } = props;
145
+ if (!Array.isArray(value) || value.length === 0) {
146
+ return <span className="squisq-jv-empty">No items</span>;
147
+ }
148
+ const itemSchema = (Array.isArray(schema.items) ? schema.items[0] : schema.items) ?? {};
149
+ const itemLabel = itemSchema.squisq?.itemLabel;
150
+ return (
151
+ <div className="squisq-jv-card-stack">
152
+ {value.map((item, i) => {
153
+ const title = resolveItemTitle(itemLabel, item, i);
154
+ return (
155
+ <div key={i} className="squisq-jv-card">
156
+ {title ? <h4 className="squisq-jv-card__title">{title}</h4> : null}
157
+ <RenderNode
158
+ value={item}
159
+ schema={itemSchema}
160
+ rootSchema={rootSchema}
161
+ rootData={rootData}
162
+ pointer={`${pointer}/${i}`}
163
+ density={density}
164
+ suppressTopGroupTitle
165
+ />
166
+ </div>
167
+ );
168
+ })}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ function resolveItemTitle(
174
+ spec: string | { fromField: string } | undefined,
175
+ item: unknown,
176
+ index: number,
177
+ ): string {
178
+ if (!spec) return `Item ${index + 1}`;
179
+ if (typeof spec === 'string') return spec;
180
+ if (item && typeof item === 'object') {
181
+ const v = (item as Record<string, unknown>)[spec.fromField];
182
+ if (typeof v === 'string' && v !== '') return v;
183
+ if (typeof v === 'number') return String(v);
184
+ }
185
+ return `Item ${index + 1}`;
186
+ }
187
+
188
+ export function GroupViewer(props: ViewerProps & { suppressTitle?: boolean }) {
189
+ const { value, schema, rootSchema, rootData, pointer, density, suppressTitle } = props;
190
+ const title = !suppressTitle ? (schema.squisq?.label ?? schema.title) : undefined;
191
+ const help = schema.squisq?.help ?? schema.description;
192
+ const obj = (value && typeof value === 'object' ? (value as Record<string, unknown>) : {}) ?? {};
193
+ const propEntries = Object.entries(schema.properties ?? {});
194
+
195
+ return (
196
+ <section className="squisq-jv-group">
197
+ {title ? <h3 className="squisq-jv-group__title">{title}</h3> : null}
198
+ {help ? <p className="squisq-jv-group__help">{help}</p> : null}
199
+ {propEntries.map(([key, propSchema]) => (
200
+ <Fragment key={key}>
201
+ <RowOrSection
202
+ label={propSchema.squisq?.label ?? propSchema.title ?? key}
203
+ help={propSchema.squisq?.help ?? propSchema.description}
204
+ kindHint={propSchema}
205
+ >
206
+ <RenderNode
207
+ value={obj[key]}
208
+ schema={propSchema}
209
+ rootSchema={rootSchema}
210
+ rootData={rootData}
211
+ pointer={`${pointer}/${key}`}
212
+ density={density}
213
+ suppressTopGroupTitle
214
+ />
215
+ </RowOrSection>
216
+ </Fragment>
217
+ ))}
218
+ </section>
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Decide whether to use the inline label/value Row layout, or to drop
224
+ * the row chrome entirely (for nested groups, card-stacks, richtext —
225
+ * which present their own headings).
226
+ */
227
+ function RowOrSection({
228
+ label,
229
+ help,
230
+ kindHint,
231
+ children,
232
+ }: {
233
+ label: string;
234
+ help?: string;
235
+ kindHint: SquisqAnnotatedSchema;
236
+ children: React.ReactNode;
237
+ }) {
238
+ const composite = isCompositeKind(kindHint);
239
+ if (composite) {
240
+ return <>{children}</>;
241
+ }
242
+ return (
243
+ <div className="squisq-jv-row">
244
+ <div className="squisq-jv-label" title={help}>
245
+ {label}
246
+ </div>
247
+ <div>{children}</div>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function isCompositeKind(schema: SquisqAnnotatedSchema): boolean {
253
+ const t = schema.type;
254
+ const arrayLike = t === 'array' || (Array.isArray(t) && t.includes('array'));
255
+ if (arrayLike) {
256
+ return arrayItemKind(schema) === 'object';
257
+ }
258
+ const objectLike = t === 'object' || (Array.isArray(t) && t.includes('object'));
259
+ if (objectLike) return true;
260
+ if (schema.oneOf || schema.anyOf) return true;
261
+ if (schema.format === 'markdown' || schema.squisq?.control === 'richtext') return true;
262
+ return false;
263
+ }
264
+
265
+ export function TabsViewer(props: ViewerProps) {
266
+ const { value, schema, rootSchema, rootData, pointer, density } = props;
267
+ const branches = (schema.oneOf ?? schema.anyOf ?? []).slice();
268
+ const matchedIndex = pickMatchingBranch(branches as SquisqAnnotatedSchema[], value);
269
+ const branch = branches[matchedIndex] as SquisqAnnotatedSchema | undefined;
270
+ if (!branch) {
271
+ return <TextViewer {...props} />;
272
+ }
273
+ return (
274
+ <div className="squisq-jv-tabs">
275
+ <span className="squisq-jv-tabs__discriminator">
276
+ {branch.squisq?.label ?? branch.title ?? `Option ${matchedIndex + 1}`}
277
+ </span>
278
+ <RenderNode
279
+ value={value}
280
+ schema={branch}
281
+ rootSchema={rootSchema}
282
+ rootData={rootData}
283
+ pointer={pointer}
284
+ density={density}
285
+ />
286
+ </div>
287
+ );
288
+ }
289
+
290
+ function pickMatchingBranch(branches: SquisqAnnotatedSchema[], value: unknown): number {
291
+ for (let i = 0; i < branches.length; i++) {
292
+ if (matchesShape(branches[i], value)) return i;
293
+ }
294
+ return 0;
295
+ }
296
+
297
+ function matchesShape(schema: SquisqAnnotatedSchema, value: unknown): boolean {
298
+ const t = schema.type;
299
+ const target = Array.isArray(t) ? t.find((x) => x !== 'null') : t;
300
+ if (!target) return true;
301
+ switch (target) {
302
+ case 'string':
303
+ return typeof value === 'string';
304
+ case 'number':
305
+ case 'integer':
306
+ return typeof value === 'number';
307
+ case 'boolean':
308
+ return typeof value === 'boolean';
309
+ case 'null':
310
+ return value === null;
311
+ case 'array':
312
+ return Array.isArray(value);
313
+ case 'object':
314
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
315
+ default:
316
+ return false;
317
+ }
318
+ }
319
+
320
+ // ── Registry ──────────────────────────────────────────────────────
321
+
322
+ // eslint-disable-next-line react-refresh/only-export-components
323
+ export const VIEWERS: Record<ControlKind, React.ComponentType<ViewerProps>> = {
324
+ text: TextViewer,
325
+ multiline: MultilineViewer,
326
+ richtext: RichTextViewer,
327
+ color: ColorViewer,
328
+ date: DateViewer,
329
+ time: DateViewer,
330
+ datetime: DateViewer,
331
+ slider: NumberViewer,
332
+ stepper: NumberViewer,
333
+ segmented: EnumViewer,
334
+ radio: EnumViewer,
335
+ combobox: EnumViewer,
336
+ toggle: BooleanViewer,
337
+ checkbox: BooleanViewer,
338
+ card: GroupViewer,
339
+ group: GroupViewer,
340
+ 'card-stack': CardStackViewer,
341
+ 'chip-bin': ChipBinViewer,
342
+ tabs: TabsViewer,
343
+ };
@@ -456,3 +456,39 @@
456
456
  background: rgba(255, 255, 255, 0.9);
457
457
  color: #1a1a1a;
458
458
  }
459
+
460
+ /* ── Inline media players ────────────────────────────────────────── */
461
+ /* Used by <InlineVideoPlayer> / <InlineAudioPlayer>, which the
462
+ * MarkdownRenderer swaps in for raw <video>/<audio> htmlElements so
463
+ * their src goes through MediaContext. The wrapper is a <span> with
464
+ * `display: inline-block` so the player flows inline with surrounding
465
+ * text but still respects its own intrinsic dimensions. */
466
+
467
+ .squisq-inline-video-player {
468
+ display: inline-block;
469
+ max-width: 480px;
470
+ width: 100%;
471
+ margin: 0.5em 0;
472
+ }
473
+
474
+ .squisq-inline-video-player video {
475
+ display: block;
476
+ width: 100%;
477
+ max-width: 100%;
478
+ height: auto;
479
+ border-radius: 4px;
480
+ background: #000;
481
+ }
482
+
483
+ .squisq-inline-audio-player {
484
+ display: inline-block;
485
+ max-width: 360px;
486
+ width: 100%;
487
+ margin: 0.25em 0;
488
+ vertical-align: middle;
489
+ }
490
+
491
+ .squisq-inline-audio-player audio {
492
+ display: block;
493
+ width: 100%;
494
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @bendyline/squisq-react — bundled stylesheet entry.
3
+ * Aggregates all CSS shipped with this package.
4
+ */
5
+
6
+ @import './doc-animations.css';
7
+ @import '../jsonView/json-view.css';