@proulr/sdui-contract 0.1.16 → 0.1.19

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": "@proulr/sdui-contract",
3
- "version": "0.1.16",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,257 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ decodeSduiScreenWire,
4
+ isSduiAvatarWireProps,
5
+ isSduiContentImageWireProps,
6
+ isSduiIconButtonWireProps,
7
+ isSduiRowOverflowMenuWireProps,
8
+ isSduiRowTemplateEntryWireProps,
9
+ isSduiRowTemplateSetWireProps,
10
+ isSduiRowTemplateWireProps,
11
+ isSduiSelectedTrue,
12
+ isSduiVideoWireProps,
13
+ } from './index';
14
+ import type { SduiBlockWire } from './sdui-contract-types';
15
+
16
+ const templateBlock: SduiBlockWire = {
17
+ id: '0.row_template.0.0',
18
+ type: 'column',
19
+ props: { action_id: 'open_post_{{item.id}}' },
20
+ children: [
21
+ { id: '0.row_template.0.0.0', type: 'text', props: { text: '{{item.author.name}}' }, children: [] },
22
+ ],
23
+ };
24
+
25
+ describe('isSduiRowTemplateWireProps', () => {
26
+ it('accepts host props carrying a row template', () => {
27
+ const props: Record<string, unknown> = {
28
+ data_ref: 'social.posts',
29
+ row_template: [{ id: '0.row_template.0.0', type: 'text', props: { text: '{{item.body}}' } }],
30
+ item_key: 'id',
31
+ section: 'posts',
32
+ };
33
+ expect(isSduiRowTemplateWireProps(props)).toBe(true);
34
+ });
35
+
36
+ it('rejects props without template or without item_key', () => {
37
+ expect(isSduiRowTemplateWireProps(undefined)).toBe(false);
38
+ expect(isSduiRowTemplateWireProps({})).toBe(false);
39
+ expect(isSduiRowTemplateWireProps({ row_template: [] })).toBe(false);
40
+ expect(isSduiRowTemplateWireProps({ row_template: [], item_key: '' })).toBe(false);
41
+ expect(isSduiRowTemplateWireProps({ row_template: 'nope', item_key: 'id' })).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe('multi-template hosts (row_templates)', () => {
46
+ const entry = (itemKey = 'id', section?: string): Record<string, unknown> => ({
47
+ row_template: [{ id: 't', type: 'text', props: { text: 'x' } }],
48
+ item_key: itemKey,
49
+ ...(section ? { section } : {}),
50
+ });
51
+
52
+ it('isSduiRowTemplateEntryWireProps validates one ordered template', () => {
53
+ expect(isSduiRowTemplateEntryWireProps(entry('id', 'posts'))).toBe(true);
54
+ expect(isSduiRowTemplateEntryWireProps(entry('post.id'))).toBe(true);
55
+ expect(isSduiRowTemplateEntryWireProps(null)).toBe(false);
56
+ expect(isSduiRowTemplateEntryWireProps({ row_template: [] })).toBe(false);
57
+ expect(isSduiRowTemplateEntryWireProps({ row_template: [], item_key: '' })).toBe(false);
58
+ expect(isSduiRowTemplateEntryWireProps({ row_template: [], item_key: 'id', section: 3 })).toBe(
59
+ false,
60
+ );
61
+ });
62
+
63
+ it('isSduiRowTemplateSetWireProps accepts single hosts (no row_templates) and valid sets', () => {
64
+ expect(
65
+ isSduiRowTemplateSetWireProps({ row_template: [], item_key: 'id' }),
66
+ ).toBe(true);
67
+ expect(
68
+ isSduiRowTemplateSetWireProps({
69
+ row_template: [],
70
+ item_key: 'id',
71
+ row_templates: [entry(), entry('comment_id', 'comments')],
72
+ }),
73
+ ).toBe(true);
74
+ });
75
+
76
+ it('isSduiRowTemplateSetWireProps rejects malformed sets', () => {
77
+ expect(
78
+ isSduiRowTemplateSetWireProps({ row_template: [], item_key: 'id', row_templates: [] }),
79
+ ).toBe(false);
80
+ expect(
81
+ isSduiRowTemplateSetWireProps({
82
+ row_template: [],
83
+ item_key: 'id',
84
+ row_templates: [{ row_template: 'nope', item_key: 'id' }],
85
+ }),
86
+ ).toBe(false);
87
+ expect(isSduiRowTemplateSetWireProps({ row_templates: [entry()] })).toBe(false);
88
+ });
89
+
90
+ it('row_templates survives the codec untouched (ordered, snake_case entries)', () => {
91
+ const screen = decodeSduiScreenWire({
92
+ schema_version: 1,
93
+ screen_id: 'social.feed',
94
+ blocks: [
95
+ {
96
+ id: 'feed',
97
+ type: 'social_post_feed',
98
+ props: {
99
+ row_template: [templateBlock],
100
+ item_key: 'id',
101
+ row_templates: [entry('id', 'posts'), entry('comment_id', 'comments')],
102
+ },
103
+ children: [],
104
+ },
105
+ ],
106
+ });
107
+ const props = screen.blocks[0].props;
108
+ expect(props.item_key).toBe('id');
109
+ expect(props.row_template).toEqual([templateBlock]);
110
+ expect(props.row_templates).toEqual([entry('id', 'posts'), entry('comment_id', 'comments')]);
111
+ });
112
+ });
113
+
114
+ describe('video block wire props', () => {
115
+ it('isSduiVideoWireProps accepts string path (binding allowed), rejects others', () => {
116
+ expect(isSduiVideoWireProps({ path: '{{item.video_path}}', aspect: 'video' })).toBe(true);
117
+ expect(isSduiVideoWireProps({ path: 'clips/2026/a.mp4', poster_path: 'clips/2026/a.jpg' })).toBe(
118
+ true,
119
+ );
120
+ expect(isSduiVideoWireProps({})).toBe(false);
121
+ expect(isSduiVideoWireProps({ poster_path: 'a.jpg' })).toBe(false);
122
+ expect(isSduiVideoWireProps(undefined)).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe('row_overflow_menu block wire props', () => {
127
+ it('isSduiRowOverflowMenuWireProps requires the actions array', () => {
128
+ expect(
129
+ isSduiRowOverflowMenuWireProps({
130
+ actions: [{ action_id: 'delete_{{item.id}}', label: 'Excluir', destructive: true }],
131
+ icon: 'overflow',
132
+ }),
133
+ ).toBe(true);
134
+ expect(isSduiRowOverflowMenuWireProps({ actions: [] })).toBe(true);
135
+ expect(isSduiRowOverflowMenuWireProps({ icon: 'overflow' })).toBe(false);
136
+ expect(isSduiRowOverflowMenuWireProps(undefined)).toBe(false);
137
+ });
138
+ });
139
+
140
+ describe('row_template wire through the codec', () => {
141
+ it('passes row_template props through untouched (blocks without template unchanged)', () => {
142
+ const templateBlock: SduiBlockWire = {
143
+ id: '0.row_template.0.0',
144
+ type: 'column',
145
+ props: { action_id: 'open_post_{{item.id}}' },
146
+ children: [
147
+ { id: '0.row_template.0.0.0', type: 'text', props: { text: '{{item.author.name}}' }, children: [] },
148
+ ],
149
+ };
150
+ const screen = decodeSduiScreenWire({
151
+ schema_version: 1,
152
+ screen_id: 'social.feed',
153
+ blocks: [
154
+ {
155
+ id: 'feed',
156
+ type: 'social_post_feed',
157
+ props: { data_ref: 'social.posts', row_template: [templateBlock], item_key: 'id' },
158
+ children: [],
159
+ },
160
+ { id: 'plain', type: 'text', props: { text: 'hello' }, children: [] },
161
+ ],
162
+ });
163
+
164
+ const host = screen.blocks[0];
165
+ expect(host.props.row_template).toEqual([templateBlock]);
166
+ expect(host.props.item_key).toBe('id');
167
+ // Nested wire blocks keep their shape (codec does not recurse into props).
168
+ const nested = (host.props.row_template as SduiBlockWire[])[0];
169
+ expect(nested.children?.[0]?.props?.text).toBe('{{item.author.name}}');
170
+ expect(screen.blocks[1].props).toEqual({ text: 'hello' });
171
+ });
172
+ });
173
+
174
+ describe('media/feed row block wire props', () => {
175
+ it('isSduiContentImageWireProps accepts string path (binding allowed), rejects others', () => {
176
+ expect(isSduiContentImageWireProps({ path: '{{item.media_path}}', aspect: 'square' })).toBe(true);
177
+ expect(isSduiContentImageWireProps({ path: 'feeds/2026/photo.jpg' })).toBe(true);
178
+ expect(isSduiContentImageWireProps({})).toBe(false);
179
+ expect(isSduiContentImageWireProps({ width: 100 })).toBe(false);
180
+ expect(isSduiContentImageWireProps(undefined)).toBe(false);
181
+ });
182
+
183
+ it('isSduiAvatarWireProps accepts path, name or initials', () => {
184
+ expect(isSduiAvatarWireProps({ path: '{{item.avatar_path}}' })).toBe(true);
185
+ expect(isSduiAvatarWireProps({ name: '{{item.author.name}}', size: 'md' })).toBe(true);
186
+ expect(isSduiAvatarWireProps({ initials: 'AB' })).toBe(true);
187
+ expect(isSduiAvatarWireProps({})).toBe(false);
188
+ expect(isSduiAvatarWireProps({ size: 'sm' })).toBe(false);
189
+ expect(isSduiAvatarWireProps(undefined)).toBe(false);
190
+ });
191
+
192
+ it('isSduiIconButtonWireProps requires string icon', () => {
193
+ expect(isSduiIconButtonWireProps({ icon: 'heart', selected: '{{item.liked_by_me}}' })).toBe(true);
194
+ expect(isSduiIconButtonWireProps({ icon: 'heart', selected: false })).toBe(true);
195
+ expect(isSduiIconButtonWireProps({ action_id: 'like' })).toBe(false);
196
+ expect(isSduiIconButtonWireProps(undefined)).toBe(false);
197
+ });
198
+
199
+ it('isSduiSelectedTrue implements the stringly-typed binding rule', () => {
200
+ expect(isSduiSelectedTrue(true)).toBe(true);
201
+ expect(isSduiSelectedTrue('true')).toBe(true);
202
+ expect(isSduiSelectedTrue(' True ')).toBe(true);
203
+ expect(isSduiSelectedTrue('false')).toBe(false);
204
+ expect(isSduiSelectedTrue(false)).toBe(false);
205
+ expect(isSduiSelectedTrue(undefined)).toBe(false);
206
+ expect(isSduiSelectedTrue(1)).toBe(false);
207
+ expect(isSduiSelectedTrue('yes')).toBe(false);
208
+ });
209
+
210
+ it('selected/media props survive the codec untouched (block props never camelCased)', () => {
211
+ const screen = decodeSduiScreenWire({
212
+ schema_version: 1,
213
+ screen_id: 'social.feed',
214
+ blocks: [
215
+ {
216
+ id: 'row',
217
+ type: 'column',
218
+ props: {},
219
+ children: [
220
+ {
221
+ id: 'row.img',
222
+ type: 'content_image',
223
+ props: { path: '{{item.media_path}}', aspect: 'square', corner: 'cornerMedium', blur_fill: false },
224
+ },
225
+ {
226
+ id: 'row.avatar',
227
+ type: 'avatar',
228
+ props: { path: '{{item.author.avatar_path}}', name: '{{item.author.name}}', size: 'sm' },
229
+ },
230
+ {
231
+ id: 'row.like',
232
+ type: 'icon_button',
233
+ props: {
234
+ icon: 'heart',
235
+ selected_icon: 'heart-filled',
236
+ action_id: 'like_post_{{item.id}}',
237
+ selected: 'true',
238
+ },
239
+ },
240
+ ],
241
+ },
242
+ ],
243
+ });
244
+
245
+ const [image, avatar, like] = screen.blocks[0].children;
246
+ expect(image.type).toBe('content_image');
247
+ expect(image.props).toEqual({
248
+ path: '{{item.media_path}}',
249
+ aspect: 'square',
250
+ corner: 'cornerMedium',
251
+ blur_fill: false,
252
+ });
253
+ expect(avatar.props.name).toBe('{{item.author.name}}');
254
+ expect(like.props.selected).toBe('true');
255
+ expect(isSduiSelectedTrue(like.props.selected)).toBe(true);
256
+ });
257
+ });
@@ -25,6 +25,331 @@ export type SduiBlockWire = {
25
25
  children?: SduiBlockWire[];
26
26
  };
27
27
 
28
+ /**
29
+ * Row template hoisted onto a host hybrid block's props by `compileBdcToWire`
30
+ * (BDC `<ListRowTemplate>` — authoring lives in proulr-sdui-components).
31
+ *
32
+ * Wire shape (snake_case, inside the host block's `props`):
33
+ * ```
34
+ * {
35
+ * row_template: SduiBlockWire[], // compiled subtree roots of the template
36
+ * item_key: string, // identifier field of each live item, e.g. 'id' (dot path allowed)
37
+ * section?: string, // optional section marker for multi-section feeds
38
+ * }
39
+ * ```
40
+ *
41
+ * Binding syntax inside template props: `{{item.field}}` / `{{item.a.b}}`
42
+ * (dot path against each live item; `{{index}}` is NOT supported). Any string
43
+ * prop value — including action ids and nested action payloads — may embed
44
+ * placeholders; the app resolves them at materialize time
45
+ * (`materializeRowTemplate` in `@proulr/sdui-components/renderer`).
46
+ * Unresolved paths render as empty string.
47
+ */
48
+ export type SduiRowTemplateWireProps = {
49
+ row_template: SduiBlockWire[];
50
+ item_key: string;
51
+ section?: string;
52
+ };
53
+
54
+ /**
55
+ * Type guard for block props carrying a `row_template` (wire/props stay uncamelCased by the codec). */
56
+ export function isSduiRowTemplateWireProps(
57
+ props: Record<string, unknown> | undefined,
58
+ ): props is SduiRowTemplateWireProps {
59
+ return (
60
+ !!props &&
61
+ Array.isArray(props.row_template) &&
62
+ typeof props.item_key === 'string' &&
63
+ props.item_key.length > 0
64
+ );
65
+ }
66
+
67
+ /**
68
+ * One ordered template of a **multi-template host** (BDC: several
69
+ * `<ListRowTemplate>` children, each carrying its own `item_key`/`section`).
70
+ * Shape is identical to `SduiRowTemplateWireProps` — it is one entry of the
71
+ * host's `props.row_templates` array.
72
+ */
73
+ export type SduiRowTemplateEntryWireProps = {
74
+ row_template: SduiBlockWire[];
75
+ item_key: string;
76
+ section?: string;
77
+ };
78
+
79
+ /**
80
+ * Wire shape for a host block compiled from **multiple** `<ListRowTemplate>`
81
+ * children (see `hoistRowTemplates` in `compileBdcToWire`):
82
+ * ```
83
+ * {
84
+ * row_template: SduiBlockWire[], // ALWAYS the FIRST template (backward compat)
85
+ * item_key: string, // item_key of the first template
86
+ * section?: string, // section of the first template (when set)
87
+ * row_templates: SduiRowTemplateEntryWireProps[], // ordered, one entry per template
88
+ * }
89
+ * ```
90
+ * Single-template hosts keep the exact `SduiRowTemplateWireProps` shape —
91
+ * `row_templates` only appears when the host has two or more templates, so
92
+ * existing readers (`readSduiRowTemplate`) keep working unchanged. New
93
+ * readers use `readSduiRowTemplates` (`@proulr/sdui-components/renderer`).
94
+ */
95
+ export type SduiRowTemplateSetWireProps = SduiRowTemplateWireProps & {
96
+ row_templates?: SduiRowTemplateEntryWireProps[];
97
+ };
98
+
99
+ /** Type guard for one entry of `row_templates`. */
100
+ export function isSduiRowTemplateEntryWireProps(
101
+ value: unknown,
102
+ ): value is SduiRowTemplateEntryWireProps {
103
+ if (!value || typeof value !== 'object') return false;
104
+ const entry = value as Record<string, unknown>;
105
+ return (
106
+ Array.isArray(entry.row_template) &&
107
+ typeof entry.item_key === 'string' &&
108
+ entry.item_key.length > 0 &&
109
+ (entry.section === undefined || typeof entry.section === 'string')
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Type guard for host props carrying an ordered template set. A plain
115
+ * single-template host also passes (`row_templates` optional); entries, when
116
+ * present, must each be a valid template.
117
+ */
118
+ export function isSduiRowTemplateSetWireProps(
119
+ props: Record<string, unknown> | undefined,
120
+ ): props is SduiRowTemplateSetWireProps {
121
+ if (!isSduiRowTemplateWireProps(props)) return false;
122
+ const set = props as SduiRowTemplateSetWireProps;
123
+ return (
124
+ set.row_templates === undefined ||
125
+ (Array.isArray(set.row_templates) &&
126
+ set.row_templates.length > 0 &&
127
+ set.row_templates.every(isSduiRowTemplateEntryWireProps))
128
+ );
129
+ }
130
+
131
+ // ─── Media / feed row blocks (generic wire blocks, snake_case) ────────────
132
+ //
133
+ // Block `props` pass through the codec untouched (see `mapBlockWire`), so the
134
+ // wire shape below is also the domain shape for these blocks. Values inside
135
+ // `path` / `selected` may embed row-template bindings (`{{item.field}}`); the
136
+ // app resolves them at materialize time BEFORE the block renders. The media
137
+ // PATH is data-driven; turning it into a fetchable URL is a generic
138
+ // app-provided capability (`SduiMediaResolver` in
139
+ // `@proulr/sdui-components/renderer`) — never product logic in the lib.
140
+
141
+ /** Aspect tokens for content images (ratio values live in the renderer lib). */
142
+ export type SduiMediaAspectToken = 'square' | 'portrait' | 'story' | 'video' | 'wide';
143
+
144
+ /** Resize tokens for content images (maps to RN resizeMode in the renderer). */
145
+ export type SduiMediaFitToken = 'cover' | 'contain';
146
+
147
+ /** Size tokens for avatars (px values live in the renderer lib). */
148
+ export type SduiAvatarSizeToken = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
149
+
150
+ /**
151
+ * State-driven icon flag (stringly-typed binding rule): `selected` is either a
152
+ * real boolean OR a string produced by row-template materialization —
153
+ * `{{item.liked_by_me}}` renders booleans as `'true'` / `'false'` strings.
154
+ * `isSduiSelectedTrue` is the single coercion contract: `true` and the string
155
+ * `'true'` (case-insensitive, trimmed) mean selected; everything else
156
+ * (`'false'`, `false`, missing, any other string) means default.
157
+ */
158
+ export type SduiSelectedFlag = string | boolean;
159
+
160
+ /** Coercion contract for `selected`-style stringly-typed wire flags. */
161
+ export function isSduiSelectedTrue(value: unknown): boolean {
162
+ if (value === true) return true;
163
+ return typeof value === 'string' && value.trim().toLowerCase() === 'true';
164
+ }
165
+
166
+ /**
167
+ * Wire shape for block `content_image` (BDC `<ContentImage>`):
168
+ * ```
169
+ * {
170
+ * path: string, // storage path; may contain {{item.*}} binding (required)
171
+ * width?: number, height?: number,
172
+ * aspect?: 'square'|'portrait'|'story'|'video'|'wide',
173
+ * corner?: ShapeToken, // default cornerMedium
174
+ * fit?: 'cover'|'contain', // default cover
175
+ * blur_fill?: boolean, // fill frame behind `contain` media
176
+ * test_id?: string
177
+ * }
178
+ * ```
179
+ * Renderer contract: empty `path` or no app `SduiMediaResolver` → renders null
180
+ * (never a product fallback URL).
181
+ */
182
+ export type SduiContentImageWireProps = {
183
+ path: string;
184
+ width?: number;
185
+ height?: number;
186
+ aspect?: SduiMediaAspectToken;
187
+ corner?: ShapeToken;
188
+ fit?: SduiMediaFitToken;
189
+ blur_fill?: boolean;
190
+ test_id?: string;
191
+ };
192
+
193
+ /** Type guard for `content_image` block props. */
194
+ export function isSduiContentImageWireProps(
195
+ props: Record<string, unknown> | undefined,
196
+ ): props is SduiContentImageWireProps {
197
+ return !!props && typeof props.path === 'string';
198
+ }
199
+
200
+ /**
201
+ * Wire shape for block `avatar` (BDC `<Avatar>`):
202
+ * ```
203
+ * {
204
+ * path?: string, // storage path; may contain {{item.*}} binding
205
+ * name?: string, // display name — initials derived when no image
206
+ * initials?: string, // explicit initials (override derivation)
207
+ * size?: 'xs'|'sm'|'md'|'lg'|'xl', // default md
208
+ * corner?: ShapeToken, // default cornerFull
209
+ * test_id?: string
210
+ * }
211
+ * ```
212
+ * No resolvable image (no path, empty path, or no resolver) falls back to
213
+ * initials / generic person glyph placeholder — never to a product URL.
214
+ */
215
+ export type SduiAvatarWireProps = {
216
+ path?: string;
217
+ name?: string;
218
+ initials?: string;
219
+ size?: SduiAvatarSizeToken;
220
+ corner?: ShapeToken;
221
+ test_id?: string;
222
+ };
223
+
224
+ /** Type guard for `avatar` block props. */
225
+ export function isSduiAvatarWireProps(
226
+ props: Record<string, unknown> | undefined,
227
+ ): props is SduiAvatarWireProps {
228
+ if (!props) return false;
229
+ return (
230
+ typeof props.path === 'string' ||
231
+ typeof props.name === 'string' ||
232
+ typeof props.initials === 'string'
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Wire shape for block `icon_button` (BDC `<IconButton>`):
238
+ * ```
239
+ * {
240
+ * icon: string, // AppIconName kebab-case (required)
241
+ * selected_icon?: string, // AppIconName used when selected (e.g. 'heart-filled')
242
+ * action_id?: string, // may contain {{item.*}} binding in row templates
243
+ * selected?: string | boolean,// SduiSelectedFlag: '{{item.liked_by_me}}' → 'true'/'false'
244
+ * size?: number, // glyph px (default feedAction)
245
+ * color?: ColorToken, // default tint (unselected)
246
+ * selected_color?: ColorToken,// default tint (selected)
247
+ * label?: string, // accessibility label (copy via Rosetta in BFF)
248
+ * disabled?: boolean,
249
+ * test_id?: string
250
+ * }
251
+ * ```
252
+ * `selected` coercion: `isSduiSelectedTrue` — `'true'`/`true` render filled/tinted
253
+ * via theme tokens, `'false'`/`false` render default.
254
+ */
255
+ export type SduiIconButtonWireProps = {
256
+ icon: string;
257
+ selected_icon?: string;
258
+ action_id?: string;
259
+ selected?: SduiSelectedFlag;
260
+ size?: number;
261
+ color?: ColorToken;
262
+ selected_color?: ColorToken;
263
+ label?: string;
264
+ disabled?: boolean;
265
+ test_id?: string;
266
+ };
267
+
268
+ /** Type guard for `icon_button` block props. */
269
+ export function isSduiIconButtonWireProps(
270
+ props: Record<string, unknown> | undefined,
271
+ ): props is SduiIconButtonWireProps {
272
+ return !!props && typeof props.icon === 'string';
273
+ }
274
+
275
+ /**
276
+ * Wire shape for block `video` (BDC `<Video>`):
277
+ * ```
278
+ * {
279
+ * path: string, // storage path; may contain {{item.*}} binding (required)
280
+ * width?: number, height?: number,
281
+ * aspect?: 'square'|'portrait'|'story'|'video'|'wide',
282
+ * corner?: ShapeToken, // default cornerMedium
283
+ * fit?: 'cover'|'contain', // default cover
284
+ * poster_path?: string, // still frame (binding ok); shown when no player plugged
285
+ * test_id?: string
286
+ * }
287
+ * ```
288
+ * Renderer contract: `path`/`poster_path` resolve through the SAME app
289
+ * capability as `content_image` (`SduiMediaResolver`); playback is an
290
+ * injectable slot (`SduiVideoPlayerProvider` in
291
+ * `@proulr/sdui-components/renderer`) — the RN player lives in the app.
292
+ * Without a player the block renders a poster/still FRAME via the resolver;
293
+ * empty `path` or no resolver → renders null (never a product fallback URL).
294
+ */
295
+ export type SduiVideoWireProps = {
296
+ path: string;
297
+ width?: number;
298
+ height?: number;
299
+ aspect?: SduiMediaAspectToken;
300
+ corner?: ShapeToken;
301
+ fit?: SduiMediaFitToken;
302
+ poster_path?: string;
303
+ test_id?: string;
304
+ };
305
+
306
+ /** Type guard for `video` block props. */
307
+ export function isSduiVideoWireProps(
308
+ props: Record<string, unknown> | undefined,
309
+ ): props is SduiVideoWireProps {
310
+ return !!props && typeof props.path === 'string';
311
+ }
312
+
313
+ /**
314
+ * One action entry of block `row_overflow_menu` (nested objects stay
315
+ * snake_case on the wire — normalized by the BDC factory, not the codec).
316
+ * `action_id` and `label` may contain `{{item.*}}` bindings (template-safe).
317
+ */
318
+ export type SduiRowOverflowMenuActionWire = {
319
+ action_id: string;
320
+ label: string;
321
+ /** AppIconName kebab-case (catalog), e.g. 'trash'. */
322
+ icon?: string;
323
+ /** Tint the entry with the theme's error token. */
324
+ destructive?: boolean;
325
+ };
326
+
327
+ /**
328
+ * Wire shape for block `row_overflow_menu` (BDC `<RowOverflowMenu>`):
329
+ * ```
330
+ * {
331
+ * actions: SduiRowOverflowMenuActionWire[], // ordered menu entries
332
+ * icon?: string, // trigger AppIconName (default 'overflow')
333
+ * test_id?: string
334
+ * }
335
+ * ```
336
+ * All copy is caller-supplied (BFF via Rosetta); the lib never invents
337
+ * product strings. Renderer opens a Modal-based menu (same mechanism as
338
+ * `floating_create_menu`) and dispatches `action_id` through `onAction`.
339
+ */
340
+ export type SduiRowOverflowMenuWireProps = {
341
+ actions: SduiRowOverflowMenuActionWire[];
342
+ icon?: string;
343
+ test_id?: string;
344
+ };
345
+
346
+ /** Type guard for `row_overflow_menu` block props. */
347
+ export function isSduiRowOverflowMenuWireProps(
348
+ props: Record<string, unknown> | undefined,
349
+ ): props is SduiRowOverflowMenuWireProps {
350
+ return !!props && Array.isArray(props.actions);
351
+ }
352
+
28
353
  export type SduiHeaderWire = {
29
354
  title?: string;
30
355
  subtitle?: string;