@proulr/sdui-components 0.1.87 → 0.1.90

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 (32) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -2
  3. package/src/bdc/compile-bdc-row-template.test.ts +179 -0
  4. package/src/bdc/compile-bdc-to-wire.ts +52 -1
  5. package/src/bdc/components/discover.tsx +5 -1
  6. package/src/bdc/components/list.test.ts +36 -0
  7. package/src/bdc/components/list.tsx +62 -0
  8. package/src/bdc/components/media.test.ts +268 -0
  9. package/src/bdc/components/media.tsx +275 -0
  10. package/src/bdc/components/native-blocks.tsx +7 -1
  11. package/src/bdc/components/ui.tsx +25 -0
  12. package/src/bdc/index.ts +5 -0
  13. package/src/catalog/block-types.ts +5 -0
  14. package/src/renderer/SduiTemplateRow.tsx +63 -0
  15. package/src/renderer/blocks/SduiContentBlocks.tsx +198 -0
  16. package/src/renderer/blocks/SduiDiscoverBlocks.tsx +37 -2
  17. package/src/renderer/blocks/SduiMediaBlocks.tsx +348 -0
  18. package/src/renderer/blocks/SduiNativeDomainBlocks.tsx +2 -0
  19. package/src/renderer/index.ts +48 -0
  20. package/src/renderer/lib/sdui-media-block-props.ts +282 -0
  21. package/src/renderer/lib/sdui-media-blocks.test.ts +279 -0
  22. package/src/renderer/lib/sdui-media-uri.ts +39 -0
  23. package/src/renderer/lib/sdui-row-template.test.ts +190 -0
  24. package/src/renderer/lib/sdui-row-template.ts +153 -0
  25. package/src/renderer/sdui-component-registry.tsx +16 -0
  26. package/src/renderer/sdui-hybrid-renderer-context.tsx +2 -0
  27. package/src/renderer/sdui-media-resolver-context.tsx +84 -0
  28. package/src/renderer/sdui-video-player-context.tsx +55 -0
  29. package/src/renderer/ui/Chip.tsx +15 -2
  30. package/src/renderer/ui/Skeleton.tsx +13 -4
  31. package/src/renderer/ui/index.ts +1 -0
  32. package/src/renderer/ui/use-reduce-motion.ts +31 -0
@@ -0,0 +1,190 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { SduiBlockWire } from '@proulr/sdui-contract';
3
+ import {
4
+ materializeRowTemplate,
5
+ readSduiRowTemplate,
6
+ readSduiRowTemplates,
7
+ resolveRowTemplateBindings,
8
+ } from './sdui-row-template';
9
+
10
+ const template: SduiBlockWire[] = [
11
+ {
12
+ id: '0.row_template.0.0',
13
+ type: 'column',
14
+ props: {
15
+ action_id: 'open_post_{{item.id}}',
16
+ tap_action: {
17
+ type: 'api_call',
18
+ path: '/v1/posts/{{item.id}}/like',
19
+ body: { comment: '{{item.author.name}} said {{item.body}}', tags: ['{{item.topic}}', 'static'] },
20
+ },
21
+ },
22
+ children: [
23
+ {
24
+ id: '0.row_template.0.0.0',
25
+ type: 'text',
26
+ props: { text: '{{item.author.name}}', fallback: '{{item.missing.deep}}' },
27
+ children: [],
28
+ },
29
+ {
30
+ id: '0.row_template.0.0.1',
31
+ type: 'icon',
32
+ props: { name: 'share', size: '{{item.likes}}' },
33
+ children: [],
34
+ },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ const item = {
40
+ id: 'p-7',
41
+ body: 'hello',
42
+ likes: 12,
43
+ topic: 'music',
44
+ author: { name: 'Ana' },
45
+ };
46
+
47
+ describe('materializeRowTemplate', () => {
48
+ it('resolves simple, nested, interpolated and multi bindings across all props', () => {
49
+ const rows = materializeRowTemplate({ row_template: template, item_key: 'id' }, item, 0);
50
+ const row = rows[0];
51
+ expect(row.props?.action_id).toBe('open_post_p-7');
52
+ const child = row.children?.[0];
53
+ expect(child?.props?.text).toBe('Ana');
54
+ expect(child?.props?.fallback).toBe('');
55
+ expect(row.children?.[1]?.props?.size).toBe('12');
56
+ expect(row.children?.[1]?.props?.name).toBe('share');
57
+ });
58
+
59
+ it('resolves bindings inside nested action payloads (objects and arrays)', () => {
60
+ const rows = materializeRowTemplate({ row_template: template, item_key: 'id' }, item, 3);
61
+ const tap = rows[0].props?.tap_action as {
62
+ path: string;
63
+ body: { comment: string; tags: string[] };
64
+ };
65
+ expect(tap.path).toBe('/v1/posts/p-7/like');
66
+ expect(tap.body.comment).toBe('Ana said hello');
67
+ expect(tap.body.tags).toEqual(['music', 'static']);
68
+ });
69
+
70
+ it('missing fields resolve to empty string; whole-placeholder and partial both work', () => {
71
+ const rows = materializeRowTemplate({ row_template: template, item_key: 'id' }, { id: 'x' }, 0);
72
+ expect(rows[0].children?.[0]?.props?.text).toBe('');
73
+ expect(rows[0].props?.action_id).toBe('open_post_x');
74
+ });
75
+
76
+ it('gives stable ids (template id + item_key value) recursively; falls back to index', () => {
77
+ const source = { row_template: template, item_key: 'id' };
78
+ const a = materializeRowTemplate(source, item, 0);
79
+ const b = materializeRowTemplate(source, item, 5);
80
+ expect(a[0].id).toBe('0.row_template.0.0@p-7');
81
+ expect(a[0].children?.[0].id).toBe('0.row_template.0.0.0@p-7');
82
+ expect(a[0].id).toBe(b[0].id);
83
+
84
+ const fallback = materializeRowTemplate(source, { body: 'no id' }, 7);
85
+ expect(fallback[0].id).toBe('0.row_template.0.0@7');
86
+
87
+ const dotKey = materializeRowTemplate({ row_template: template, item_key: 'author.name' }, item, 0);
88
+ expect(dotKey[0].id).toBe('0.row_template.0.0@Ana');
89
+ });
90
+
91
+ it('accepts a bare block array and deep-clones without mutating the template', () => {
92
+ const frozenProps = template[0].props;
93
+ const rows = materializeRowTemplate(template, item, 1);
94
+ expect(rows[0].props).not.toBe(frozenProps);
95
+ expect(template[0].props?.action_id).toBe('open_post_{{item.id}}');
96
+ expect(rows[0].props).not.toBe(template[0].props);
97
+ expect(rows[0].children?.[0]).not.toBe(template[0].children?.[0]);
98
+ // Bare array has no item_key → index fallback.
99
+ expect(rows[0].id).toBe('0.row_template.0.0@1');
100
+ });
101
+
102
+ it('tolerates empty/missing template parts', () => {
103
+ expect(materializeRowTemplate([], item, 0)).toEqual([]);
104
+ expect(materializeRowTemplate({ row_template: [], item_key: 'id' }, item, 0)).toEqual([]);
105
+ expect(
106
+ materializeRowTemplate({ row_template: [{ id: 'x', type: 'text' }], item_key: 'id' }, item, 0)[0],
107
+ ).toMatchObject({ id: 'x@p-7', props: {}, children: [] });
108
+ });
109
+ });
110
+
111
+ describe('resolveRowTemplateBindings', () => {
112
+ it('resolves strings, arrays, objects and leaves non-strings untouched', () => {
113
+ expect(resolveRowTemplateBindings('{{item.likes}} x {{item.nope}}', item)).toBe('12 x ');
114
+ expect(resolveRowTemplateBindings(42, item)).toBe(42);
115
+ expect(resolveRowTemplateBindings(null, item)).toBe(null);
116
+ expect(resolveRowTemplateBindings(['{{item.id}}'], item)).toEqual(['p-7']);
117
+ expect(resolveRowTemplateBindings({ k: { j: '{{item.id}}' }, n: 1 }, item)).toEqual({
118
+ k: { j: 'p-7' },
119
+ n: 1,
120
+ });
121
+ });
122
+
123
+ it('supports array index dot paths and whole-value objects via JSON', () => {
124
+ expect(resolveRowTemplateBindings('{{item.tags.1}}', { tags: ['a', 'b'] })).toBe('b');
125
+ expect(resolveRowTemplateBindings('{{item.a.b.c}}', {})).toBe('');
126
+ });
127
+ });
128
+
129
+ describe('readSduiRowTemplate', () => {
130
+ it('reads host block props and rejects non-template props', () => {
131
+ expect(
132
+ readSduiRowTemplate({ row_template: [], item_key: 'id', data_ref: 'x' })?.item_key,
133
+ ).toBe('id');
134
+ expect(readSduiRowTemplate({ data_ref: 'x' })).toBeUndefined();
135
+ expect(readSduiRowTemplate(undefined)).toBeUndefined();
136
+ });
137
+ });
138
+
139
+ describe('readSduiRowTemplates', () => {
140
+ it('falls back to the single template as a length-1 array (old hosts unchanged)', () => {
141
+ expect(
142
+ readSduiRowTemplates({ row_template: template, item_key: 'id', data_ref: 'social.posts' }),
143
+ ).toEqual([{ row_template: template, item_key: 'id' }]);
144
+ expect(
145
+ readSduiRowTemplates({ row_template: template, item_key: 'id', section: 'posts' }),
146
+ ).toEqual([{ row_template: template, item_key: 'id', section: 'posts' }]);
147
+ });
148
+
149
+ it('returns the ordered row_templates when a multi-template host carries them', () => {
150
+ const entries = [
151
+ { row_template: template, item_key: 'id', section: 'posts' },
152
+ { row_template: template, item_key: 'comment_id' },
153
+ ];
154
+ expect(
155
+ readSduiRowTemplates({
156
+ row_template: template,
157
+ item_key: 'id',
158
+ section: 'posts',
159
+ row_templates: entries,
160
+ }),
161
+ ).toEqual(entries);
162
+ });
163
+
164
+ it('rejects non-template props and ignores malformed row_templates (fallback to single)', () => {
165
+ expect(readSduiRowTemplates({ data_ref: 'x' })).toBeUndefined();
166
+ expect(readSduiRowTemplates(undefined)).toBeUndefined();
167
+ expect(
168
+ readSduiRowTemplates({
169
+ row_template: template,
170
+ item_key: 'id',
171
+ row_templates: [{ row_template: 'nope', item_key: 'id' }],
172
+ }),
173
+ ).toEqual([{ row_template: template, item_key: 'id' }]);
174
+ });
175
+
176
+ it('entries feed materializeRowTemplate directly (same source shape)', () => {
177
+ const entries = readSduiRowTemplates({
178
+ row_template: template,
179
+ item_key: 'id',
180
+ row_templates: [
181
+ { row_template: [{ id: 't0', type: 'text', props: { text: '{{item.body}}' } }], item_key: 'id' },
182
+ { row_template: [{ id: 't1', type: 'text', props: { text: '{{item.author.name}}' } }], item_key: 'id' },
183
+ ],
184
+ });
185
+ expect(entries).toHaveLength(2);
186
+ expect(materializeRowTemplate(entries?.[1] ?? [], item, 3)).toMatchObject([
187
+ { id: 't1@p-7', props: { text: 'Ana' } },
188
+ ]);
189
+ });
190
+ });
@@ -0,0 +1,153 @@
1
+ import type {
2
+ SduiBlockWire,
3
+ SduiRowTemplateEntryWireProps,
4
+ SduiRowTemplateSetWireProps,
5
+ SduiRowTemplateWireProps,
6
+ } from '@proulr/sdui-contract';
7
+ import { isSduiRowTemplateEntryWireProps, isSduiRowTemplateWireProps } from '@proulr/sdui-contract';
8
+
9
+ /**
10
+ * Materialização de row templates (SDUI 100%): o BFF autorretrata o layout da
11
+ * linha no wire (`props.row_template` + `props.item_key`, via `<ListRowTemplate>`
12
+ * do BDC); o híbrido do app clona o subtree por item vivo de `screen.data` e
13
+ * resolve os bindings `{{item.campo}}` antes de passar ao renderer de blocos.
14
+ *
15
+ * Binding syntax: `{{item.field}}`, `{{item.a.b}}` (dot path; segmentos com
16
+ * `$`/`_`/letras/dígitos/`-`). Substituição em **qualquer** string de prop —
17
+ * incluindo `action_id` e payloads de action aninhados (objetos/arrays).
18
+ * Só `item.*` é suportado (não há `{{index}}`). Path não resolvido → string
19
+ * vazia.
20
+ *
21
+ * Ids estáveis: cada bloco clonado ganha sufixo `@<rowKey>`, onde `rowKey` é o
22
+ * valor de `item_key` do item (dot path; fallback = `index`). Duas renderizações
23
+ * do mesmo item produzem os mesmos ids (React keys / diffs estáveis).
24
+ */
25
+
26
+ /** Fonte do template: ou o objeto de props do hospedeiro, ou só o array de blocos. */
27
+ export type SduiRowTemplateSource =
28
+ | SduiBlockWire[]
29
+ | Pick<SduiRowTemplateWireProps, 'row_template' | 'item_key' | 'section'>;
30
+
31
+ const BINDING_RE = /\{\{\s*item((?:\.[A-Za-z0-9_$-]+)*)\s*\}\}/g;
32
+
33
+ function lookupDotPath(item: unknown, path: string): unknown {
34
+ if (path === '') return undefined;
35
+ let current: unknown = item;
36
+ for (const segment of path.split('.')) {
37
+ if (current == null) return undefined;
38
+ if (Array.isArray(current) && /^\d+$/.test(segment)) {
39
+ current = current[Number(segment)];
40
+ continue;
41
+ }
42
+ if (typeof current !== 'object') return undefined;
43
+ current = (current as Record<string, unknown>)[segment];
44
+ }
45
+ return current;
46
+ }
47
+
48
+ function stringifyBinding(value: unknown): string {
49
+ if (value == null) return '';
50
+ if (typeof value === 'string') return value;
51
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
52
+ return String(value);
53
+ }
54
+ return JSON.stringify(value) ?? '';
55
+ }
56
+
57
+ function resolveString(value: string, item: unknown): string {
58
+ return value.replace(BINDING_RE, (_match, path: string) =>
59
+ stringifyBinding(lookupDotPath(item, path.slice(1))),
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Resolve bindings `{{item.*}}` em qualquer string de um valor profundo
65
+ * (strings, arrays, objetos; chaves não são tocadas). Retorna cópia — nunca
66
+ * muta a entrada.
67
+ */
68
+ export function resolveRowTemplateBindings(value: unknown, item: unknown): unknown {
69
+ if (typeof value === 'string') return resolveString(value, item);
70
+ if (Array.isArray(value)) return value.map((entry) => resolveRowTemplateBindings(entry, item));
71
+ if (value && typeof value === 'object') {
72
+ const out: Record<string, unknown> = {};
73
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
74
+ out[key] = resolveRowTemplateBindings(entry, item);
75
+ }
76
+ return out;
77
+ }
78
+ return value;
79
+ }
80
+
81
+ function materializeBlock(
82
+ block: SduiBlockWire,
83
+ item: unknown,
84
+ rowKey: string,
85
+ ): SduiBlockWire {
86
+ return {
87
+ id: `${block.id}@${rowKey}`,
88
+ type: block.type,
89
+ props: resolveRowTemplateBindings(block.props ?? {}, item) as Record<string, unknown>,
90
+ children: (block.children ?? []).map((child) => materializeBlock(child, item, rowKey)),
91
+ };
92
+ }
93
+
94
+ /** Lê o template promovido no `props` de um bloco hospedeiro (wire ou domain). */
95
+ export function readSduiRowTemplate(
96
+ props: Record<string, unknown> | undefined,
97
+ ): SduiRowTemplateWireProps | undefined {
98
+ return isSduiRowTemplateWireProps(props) ? props : undefined;
99
+ }
100
+
101
+ /**
102
+ * Lê a lista **ordenada** de templates de um bloco hospedeiro multi-template:
103
+ * - `props.row_templates` presente e válido → esse array (um entry por
104
+ * `<ListRowTemplate>`, cada um com seu `item_key`/`section`);
105
+ * - senão, fallback: o template único (`row_template` + `item_key` +
106
+ * `section?`) como array de comprimento 1 — hosts antigos funcionam
107
+ * inalterados;
108
+ * - props sem template → `undefined`.
109
+ */
110
+ export function readSduiRowTemplates(
111
+ props: Record<string, unknown> | undefined,
112
+ ): SduiRowTemplateEntryWireProps[] | undefined {
113
+ if (!isSduiRowTemplateWireProps(props)) return undefined;
114
+ const set = props as SduiRowTemplateSetWireProps;
115
+ const templates = set.row_templates;
116
+ if (
117
+ Array.isArray(templates) &&
118
+ templates.length > 0 &&
119
+ templates.every(isSduiRowTemplateEntryWireProps)
120
+ ) {
121
+ return templates;
122
+ }
123
+ const single: SduiRowTemplateEntryWireProps = {
124
+ row_template: set.row_template,
125
+ item_key: set.item_key,
126
+ };
127
+ if (typeof set.section === 'string' && set.section) single.section = set.section;
128
+ return [single];
129
+ }
130
+
131
+ /**
132
+ * Clona profundamente os blocos do template e resolve `{{item.*}}` em todas as
133
+ * props (incl. action ids e payloads). `template` aceita o objeto
134
+ * `{ row_template, item_key, section }` (props do hospedeiro) ou o array nu de
135
+ * blocos. `index` é o fallback de `rowKey` quando `item_key` não resolve.
136
+ */
137
+ export function materializeRowTemplate(
138
+ template: SduiRowTemplateSource,
139
+ item: unknown,
140
+ index = 0,
141
+ ): SduiBlockWire[] {
142
+ const blocks = Array.isArray(template) ? template : template.row_template ?? [];
143
+ const itemKey = Array.isArray(template) ? undefined : template.item_key;
144
+
145
+ let rowKey = '';
146
+ if (itemKey) {
147
+ const raw = lookupDotPath(item, itemKey);
148
+ if (raw != null && raw !== '') rowKey = stringifyBinding(raw);
149
+ }
150
+ if (!rowKey) rowKey = String(index);
151
+
152
+ return blocks.map((block) => materializeBlock(block, item, rowKey));
153
+ }
@@ -31,6 +31,13 @@ import {
31
31
  SduiProfileCompletionBannerBlock,
32
32
  SduiStoryRailBlock,
33
33
  } from './blocks/SduiSocialBlocks';
34
+ import {
35
+ SduiAvatarBlock,
36
+ SduiContentImageBlock,
37
+ SduiIconButtonBlock,
38
+ SduiRowOverflowMenuBlock,
39
+ SduiVideoBlock,
40
+ } from './blocks/SduiMediaBlocks';
34
41
  import {
35
42
  SduiChatInboxCapabilitiesBlock,
36
43
  SduiEmptyStateBlock,
@@ -44,6 +51,7 @@ import {
44
51
  } from './blocks/SduiInboxBlocks';
45
52
  import { SduiNativeAdBlock } from './blocks/SduiNativeAdBlock';
46
53
  import { SduiQrCodeBlock } from './blocks/SduiQrCodeBlock';
54
+ import { SduiStatTileRowBlock, SduiDataGroupListBlock, SduiVouchListBlock } from './blocks/SduiContentBlocks';
47
55
  import {
48
56
  SduiAlbumDetailBlock,
49
57
  SduiAlbumGridBlock,
@@ -88,11 +96,19 @@ const REGISTRY: Record<string, SduiBlockComponent> = {
88
96
  native_ad: SduiNativeAdBlock,
89
97
  card: SduiCardBlock,
90
98
  section_header: SduiSectionHeaderBlock,
99
+ stat_tile_row: SduiStatTileRowBlock,
100
+ data_group_list: SduiDataGroupListBlock,
101
+ vouch_list: SduiVouchListBlock,
91
102
  list_item: SduiListItemBlock,
92
103
  toggle_row: SduiToggleRowBlock,
93
104
  select_row: SduiSelectRowBlock,
94
105
  chip: SduiChipBlock,
95
106
  chip_row: SduiChipRowBlock,
107
+ content_image: SduiContentImageBlock,
108
+ video: SduiVideoBlock,
109
+ avatar: SduiAvatarBlock,
110
+ icon_button: SduiIconButtonBlock,
111
+ row_overflow_menu: SduiRowOverflowMenuBlock,
96
112
  segmented_tabs: SduiSegmentedTabsBlock,
97
113
  empty_state: SduiEmptyStateBlock,
98
114
  inbox_person_row: SduiInboxPersonRowBlock,
@@ -46,6 +46,8 @@ export type SduiNativeBlockRenderProps = {
46
46
  block: SduiBlock;
47
47
  data: Record<string, unknown>;
48
48
  onAction: (actionId: string, vars?: Record<string, string>) => void;
49
+ /** Nós SDUI já compilados dos blocos filhos do bloco nativo (ex.: corpo do hero). */
50
+ children?: ReactNode;
49
51
  dataRef?: string;
50
52
  conversationIdRef?: string;
51
53
  peerIdRef?: string;
@@ -0,0 +1,84 @@
1
+ import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
2
+ import { invokeSduiMediaResolver, type SduiMediaResolver } from './lib/sdui-media-uri';
3
+
4
+ export type { SduiMediaResolver };
5
+
6
+ /**
7
+ * Contexto genérico de resolução de mídia (capability do app, não do BFF):
8
+ * o app provê `(path) => URL` com **download autenticado** (+ signed só se GET
9
+ * ok). Blocos de mídia do renderer (`content_image`, `avatar`) só consomem
10
+ * este contexto — a lib nunca conhece buckets, tokens nem paths de produto.
11
+ *
12
+ * ```tsx
13
+ * // proulr-app (shell genérico, fora do RootNavigator):
14
+ * <SduiMediaResolverProvider resolveMediaUrl={(path) => authenticatedDownloadUrl(path)}>
15
+ * <SduiScreenRenderer ... />
16
+ * </SduiMediaResolverProvider>
17
+ * ```
18
+ */
19
+ const SduiMediaResolverContext = createContext<SduiMediaResolver | null>(null);
20
+
21
+ export function SduiMediaResolverProvider({
22
+ resolveMediaUrl,
23
+ children,
24
+ }: {
25
+ resolveMediaUrl: SduiMediaResolver | null;
26
+ children: ReactNode;
27
+ }) {
28
+ return (
29
+ <SduiMediaResolverContext.Provider value={resolveMediaUrl}>
30
+ {children}
31
+ </SduiMediaResolverContext.Provider>
32
+ );
33
+ }
34
+
35
+ /** Resolver fornecido pelo app, ou `null` quando a capability está desligada. */
36
+ export function useSduiMediaResolver(): SduiMediaResolver | null {
37
+ return useContext(SduiMediaResolverContext);
38
+ }
39
+
40
+ /**
41
+ * Uri resolvida para um path de mídia. Estados:
42
+ * - `uri`: URL pronta para o componente de imagem (ou `null`);
43
+ * - `pending`: resolver assíncrono ainda em voo — o bloco mantém placeholder
44
+ * de tamanho estável (anti-flicker: sem animação de opacidade no mount);
45
+ * - `settled`: a resolução terminou (sucesso, vazio ou erro). Antes do primeiro
46
+ * settle, `settled=false` — o bloco mantém placeholder em vez de colapsar
47
+ * (o `useEffect` roda pós-mount; sem este flag renderizaria `null` por um
48
+ * frame e causaria flicker de layout).
49
+ * Sem resolver / path vazio / resolução vazia / erro → `uri: null, settled: true`.
50
+ */
51
+ export function useSduiMediaUri(path: string): {
52
+ uri: string | null;
53
+ pending: boolean;
54
+ settled: boolean;
55
+ } {
56
+ const resolver = useSduiMediaResolver();
57
+ const [state, setState] = useState<{ uri: string | null; pending: boolean; settled: boolean }>({
58
+ uri: null,
59
+ pending: false,
60
+ settled: false,
61
+ });
62
+
63
+ useEffect(() => {
64
+ let cancelled = false;
65
+ const result = invokeSduiMediaResolver(resolver, path);
66
+ if (result === null) {
67
+ setState({ uri: null, pending: false, settled: true });
68
+ return;
69
+ }
70
+ if (typeof result === 'string') {
71
+ setState({ uri: result, pending: false, settled: true });
72
+ return;
73
+ }
74
+ setState({ uri: null, pending: true, settled: false });
75
+ result.then((uri) => {
76
+ if (!cancelled) setState({ uri, pending: false, settled: true });
77
+ });
78
+ return () => {
79
+ cancelled = true;
80
+ };
81
+ }, [resolver, path]);
82
+
83
+ return state;
84
+ }
@@ -0,0 +1,55 @@
1
+ import { createContext, useContext, type ReactNode } from 'react';
2
+ import type { SduiBlock } from '@proulr/sdui-contract';
3
+ import type { SduiVideoParsed } from './lib/sdui-media-block-props';
4
+
5
+ /**
6
+ * Slot injetável de playback de vídeo (capability do **app**, não da lib):
7
+ * o bloco `video` do renderer resolve `path`/`poster_path` via o mesmo
8
+ * `SduiMediaResolverContext` (download autenticado continua no app) e delega
9
+ * a renderização do player a este contexto — o app pluga `expo-video` quando
10
+ * quiser. **Sem provider**, o bloco renderiza um frame estático com o poster
11
+ * (still via resolver) — nunca URL/path de produto hardcoded.
12
+ *
13
+ * ```tsx
14
+ * // proulr-app (shell genérico):
15
+ * <SduiVideoPlayerProvider renderVideoPlayer={ExpoVideoPlayerSlot}>
16
+ * <SduiScreenRenderer ... />
17
+ * </SduiVideoPlayerProvider>
18
+ * ```
19
+ */
20
+ export type SduiVideoPlayerProps = {
21
+ block: SduiBlock;
22
+ data: Record<string, unknown>;
23
+ onAction: (actionId: string, vars?: Record<string, string>) => void;
24
+ /** Frame já dimensionado pela lib (width/height/aspect/corner/testId do wire). */
25
+ parsed: SduiVideoParsed;
26
+ /** URI do vídeo resolvida pelo `SduiMediaResolver` (null enquanto pendente/sem resolver). */
27
+ uri: string | null;
28
+ pending: boolean;
29
+ settled: boolean;
30
+ /** URI do poster resolvida (null quando não há `poster_path` ou não resolve). */
31
+ posterUri: string | null;
32
+ };
33
+
34
+ export type SduiVideoPlayer = (props: SduiVideoPlayerProps) => ReactNode;
35
+
36
+ const SduiVideoPlayerContext = createContext<SduiVideoPlayer | null>(null);
37
+
38
+ export function SduiVideoPlayerProvider({
39
+ renderVideoPlayer,
40
+ children,
41
+ }: {
42
+ renderVideoPlayer: SduiVideoPlayer | null;
43
+ children: ReactNode;
44
+ }) {
45
+ return (
46
+ <SduiVideoPlayerContext.Provider value={renderVideoPlayer}>
47
+ {children}
48
+ </SduiVideoPlayerContext.Provider>
49
+ );
50
+ }
51
+
52
+ /** Player fornecido pelo app, ou `null` → o bloco usa o frame de poster default. */
53
+ export function useSduiVideoPlayer(): SduiVideoPlayer | null {
54
+ return useContext(SduiVideoPlayerContext);
55
+ }
@@ -18,6 +18,7 @@ const CHIP_HIT_SLOP = { top: 4, bottom: 4, left: 2, right: 2 };
18
18
  export function Chip({
19
19
  label,
20
20
  active,
21
+ tone,
21
22
  onPress,
22
23
  disabled,
23
24
  icon,
@@ -25,16 +26,28 @@ export function Chip({
25
26
  }: {
26
27
  label: string;
27
28
  active?: boolean;
29
+ /** Cor de destaque da pílula de conteúdo: 'primary' (realce) ou 'secondary' (relacionado). */
30
+ tone?: 'primary' | 'secondary';
28
31
  onPress?: () => void;
29
32
  disabled?: boolean;
30
33
  icon?: string;
31
34
  testID?: string;
32
35
  }) {
36
+ const toneColor = tone === 'secondary' ? m3Colors.secondary : m3Colors.primary;
37
+ const toneBorder = tone === 'secondary' ? m3Colors.secondaryContainer : m3Colors.primaryContainer;
33
38
  const containerStyle = active
34
39
  ? { backgroundColor: m3Colors.inverseSurface, borderColor: m3Colors.inverseSurface }
35
- : { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder };
40
+ : tone
41
+ ? { backgroundColor: toneBorder, borderColor: toneColor }
42
+ : { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder };
36
43
 
37
- const textColor = disabled ? m3Colors.onDisabled : active ? colors.headerStart : colors.textOnAccent;
44
+ const textColor = disabled
45
+ ? m3Colors.onDisabled
46
+ : active
47
+ ? colors.headerStart
48
+ : tone
49
+ ? toneColor
50
+ : colors.textOnAccent;
38
51
  const iconName = icon && isAppIconName(icon) ? icon : null;
39
52
  const focus = useFocusRing();
40
53
 
@@ -9,19 +9,28 @@ import Animated, {
9
9
  withTiming,
10
10
  } from 'react-native-reanimated';
11
11
  import { m3Colors, m3Shape, spacing } from '../theme/proulr-theme';
12
+ import { useReduceMotion } from './use-reduce-motion';
12
13
 
13
14
  const PULSE_DURATION_MS = 900;
14
15
  const PULSE_MIN_OPACITY = 0.45;
16
+ const PULSE_STATIC_OPACITY = 0.7;
15
17
 
16
18
  /**
17
19
  * Pulso de opacidade compartilhado por cada bloco de skeleton. Respeita o
18
- * "reduzir movimento" do sistema (fica estatico). Como `react-native-reanimated`
19
- * ja e usado nos primitivos do pacote (ex.: RangeSlider), importamos direto.
20
+ * "reduzir movimento" do sistema: o loop infinito é desligado por completo
21
+ * (com `ReduceMotion.System` puro, o `withRepeat` alternaria 0↠1 a cada
22
+ * frame, virando strobe). Como `react-native-reanimated` ja e usado nos
23
+ * primitivos do pacote (ex.: RangeSlider), importamos direto.
20
24
  */
21
25
  function usePulseStyle() {
22
- const progress = useSharedValue(PULSE_MIN_OPACITY);
26
+ const reducedMotion = useReduceMotion();
27
+ const progress = useSharedValue(reducedMotion ? PULSE_STATIC_OPACITY : PULSE_MIN_OPACITY);
23
28
 
24
29
  useEffect(() => {
30
+ if (reducedMotion) {
31
+ progress.value = PULSE_STATIC_OPACITY;
32
+ return;
33
+ }
25
34
  progress.value = withRepeat(
26
35
  withTiming(1, {
27
36
  duration: PULSE_DURATION_MS,
@@ -31,7 +40,7 @@ function usePulseStyle() {
31
40
  -1,
32
41
  true,
33
42
  );
34
- }, [progress]);
43
+ }, [progress, reducedMotion]);
35
44
 
36
45
  return useAnimatedStyle(() => ({ opacity: progress.value }));
37
46
  }
@@ -89,3 +89,4 @@ export { StoryComposeHeader } from './story-compose-header';
89
89
  export { StoryCreateShortcutRow, type StoryCreateShortcutItem } from './story-create-shortcut-row';
90
90
  export { StoryEditorChrome, type StoryEditorTool } from './story-editor-chrome';
91
91
  export { StorySurfacePicker, type StorySurfaceOption } from './story-surface-picker';
92
+ export { useReduceMotion } from './use-reduce-motion';
@@ -0,0 +1,31 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { AccessibilityInfo, Platform } from 'react-native';
3
+
4
+ /** Fonte única do "Reduzir movimento" — web lê `prefers-reduced-motion` via matchMedia, nativo via AccessibilityInfo. */
5
+ export function useReduceMotion(): boolean {
6
+ const [reduced, setReduced] = useState(false);
7
+
8
+ useEffect(() => {
9
+ if (Platform.OS === 'web') {
10
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
11
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)');
12
+ setReduced(query.matches);
13
+ const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
14
+ query.addEventListener('change', onChange);
15
+ return () => query.removeEventListener('change', onChange);
16
+ }
17
+ let active = true;
18
+ AccessibilityInfo.isReduceMotionEnabled().then((value) => {
19
+ if (active) setReduced(value);
20
+ });
21
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (value) => {
22
+ if (active) setReduced(value);
23
+ });
24
+ return () => {
25
+ active = false;
26
+ subscription?.remove?.();
27
+ };
28
+ }, []);
29
+
30
+ return reduced;
31
+ }