diff --git a/CHANGELOG.md b/CHANGELOG.md index fc985719..14cbc5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Features +- **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. + ### Documentation - **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Cross-platform divergences — row-height tables, typography, the Android sticky-header renderer, list-wide source scale — are registered rather than changed. diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 935012e0..5da23b0f 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -41,9 +41,22 @@ The three renderers cannot constrain each other, but they all read the same | Known divergences | **This document + PR review** | §6 | A style token is resolved on the JavaScript side. `{ token: '$bodyLg' }` becomes -`{ fontSize: 16, lineHeight: 24, fontWeight: 'medium' }` before it crosses the -bridge. Native renderers never learn the token vocabulary and therefore cannot -drift from it. Raw numeric overrides stay available for pixel-parity work. +`{ fontSize: 16, lineHeight: 24, fontWeight: 'regular' }` before it crosses the +bridge — and an explicit `fontSize` alongside the token wins. Native renderers +never learn the token vocabulary and therefore cannot drift from it. Raw numeric +overrides stay available for pixel-parity work. Resolution is idempotent, and a +snapshot with nothing to resolve is returned unchanged, so the common path keeps +object identity. + +### Status + +| Layer | State | +| --- | --- | +| Contract, validation, token resolution, patch support | Implemented | +| Web — text slots for every template, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented | +| Web — `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`; the others need a per-template default gap that cannot be read back from the DOM | +| iOS, Android | Not started | +| Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | ## 3. T1 — Design tokens @@ -119,6 +132,12 @@ every platform `metricCard` renders its *value* through the title label and its views would therefore mis-target. `market` already follows this rule with `style.price` / `style.change`. +On Web the element rendering a field carries `data-nl-slot=""`, so the +style pass resolves a slot by name rather than by CSS class — on its own, +`.ok-native-list-secondary` is the identity subtitle, the rail status, a metric +label, a data column's secondary text, and a system message. iOS and Android will +need the same field-to-view mapping expressed in native code. + Legend: **=** all three platforms agree; **≠** registered divergence, see §6. ### identity @@ -412,7 +431,7 @@ belongs there, so none of the 13 binders change. | --- | --- | --- | | iOS | `NativeListCell.bind()`, after the `switch item.type` | Also covers box metrics: the four root constraints, stack spacings, leading size | | Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | -| Web | `renderElement()`, after `createRowBody()` | The selector inline overrides already live here | +| Web | `renderElement()`, after `createRowBody()` — **implemented** as `applyRowStyle()` | The selector inline overrides already live here | The existing market helpers generalize rather than being rewritten: `applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 8794c800..2bbe119e 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2,9 +2,11 @@ import type { IdentityRow, NativeListSnapshot, RowModel } from '../models'; import { WEB_LIST_CSS, WEB_REORDER_ANIMATION, + applyRowStyle, canStartWebWalletGroupReorder, cancelWebReorderRows, computeWebListLayout, + createRowBody, estimateWebRowHeight, hasExceededWebReorderMouseThreshold, isWebRowReorderable, @@ -472,3 +474,84 @@ describe('NativeList pure DOM web layout', () => { } }); }); + +describe('web row style', () => { + // The workspace ships jsdom without its type package; type the one entry used. + const { JSDOM } = require('jsdom') as { + JSDOM: new (html: string) => { window: { document: Document } }; + }; + + const render = (styledRow: RowModel): HTMLElement => { + const { document } = new JSDOM('').window; + const body = createRowBody( + { + document, + snapshot: { + schemaVersion: 1, + generation: 1, + layout: { kind: 'linear' }, + rows: [styledRow], + }, + selectedKeys: new Set(), + itemIndex: 0, + }, + styledRow + ); + applyRowStyle(body, styledRow); + return body; + }; + + it('tags each slot with the model field it renders', () => { + const body = render({ + type: 'message', + key: 'notification', + title: 'Title', + body: 'Body', + time: '1m', + }); + expect(body.querySelector('[data-nl-slot="title"]')?.textContent).toBe( + 'Title' + ); + expect(body.querySelector('[data-nl-slot="body"]')?.textContent).toBe( + 'Body' + ); + expect(body.querySelector('[data-nl-slot="time"]')?.textContent).toBe('1m'); + }); + + it('styles the named model field, not the view that carries it', () => { + // metricCard renders `value` through the view identity uses for `title`. + const body = render({ + type: 'metricCard', + key: 'kpi', + title: 'Volume', + value: '42', + style: { + title: { fontSize: 11 }, + value: { fontSize: 22, fontWeight: 'bold' }, + }, + }); + const label = body.querySelector('[data-nl-slot="title"]'); + const value = body.querySelector('[data-nl-slot="value"]'); + expect(label?.textContent).toBe('Volume'); + expect(label?.style.fontSize).toBe('11px'); + expect(value?.textContent).toBe('42'); + expect(value?.style.fontSize).toBe('22px'); + expect(value?.style.fontWeight).toBe('700'); + }); + + it('applies box padding only when the row asks for it', () => { + const base: RowModel = { + type: 'identity', + key: 'btc', + leading: { kind: 'icon', name: 'coin' }, + title: 'Bitcoin', + }; + expect(render(base).style.paddingInline).toBe(''); + const styled = render({ + ...base, + style: { horizontalPadding: 16, verticalPadding: 10 }, + } as RowModel); + expect(styled.style.paddingInline).toBe('16px'); + expect(styled.style.paddingBlock).toBe('10px'); + }); +}); diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 90d7b229..8aa3974a 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1229,3 +1229,93 @@ describe('NativeList patches', () => { ).toThrow('bodyLines'); }); }); + +describe('NativeList style contract', () => { + const styled = (style: unknown): NativeListSnapshot => + snapshot([{ ...row('btc'), style } as RowModel]); + + it('resolves a typography token to numbers before serialization', () => { + const [first] = validateSnapshot( + styled({ title: { token: '$bodyLg' }, horizontalPadding: 16 }) + ).rows; + expect((first as IdentityRow).style).toEqual({ + title: { fontSize: 16, lineHeight: 24, fontWeight: 'regular' }, + horizontalPadding: 16, + }); + expect( + serializeSnapshot(styled({ title: { token: '$bodyLg' } })) + ).not.toContain('$bodyLg'); + }); + + it('lets an explicit value override the token it resolves', () => { + const [first] = validateSnapshot( + styled({ title: { token: '$bodyLg', fontSize: 15 } }) + ).rows; + expect((first as IdentityRow).style?.title).toEqual({ + fontSize: 15, + lineHeight: 24, + fontWeight: 'regular', + }); + }); + + it('keeps snapshot identity when nothing needs resolving', () => { + const input = snapshot(); + expect(validateSnapshot(input)).toBe(input); + }); + + it('rejects a style key the template does not declare', () => { + expect(() => validateSnapshot(styled({ price: { fontSize: 12 } }))).toThrow( + 'is not a style key of the "identity" template' + ); + }); + + it('rejects an unknown token and out-of-range metrics', () => { + expect(() => + validateSnapshot(styled({ title: { token: '$displayXl' } })) + ).toThrow('token'); + expect(() => validateSnapshot(styled({ title: { fontSize: 72 } }))).toThrow( + 'fontSize' + ); + expect(() => validateSnapshot(styled({ lineGap: 17 }))).toThrow('lineGap'); + }); + + it('validates and resolves a style carried by a patch', () => { + const patches = validatePatches([ + { + type: 'identity', + key: 'btc', + changes: { style: { subtitle: { token: '$bodySm' } } }, + } as unknown as RowPatch, + ]); + expect( + (patches[0] as unknown as { changes: { style: { subtitle: unknown } } }) + .changes.style.subtitle + ).toEqual({ fontSize: 12, lineHeight: 16, fontWeight: 'regular' }); + expect(() => + validatePatches([ + { + type: 'identity', + key: 'btc', + changes: { style: { change: {} } }, + } as unknown as RowPatch, + ]) + ).toThrow('is not a style key of the "identity" template'); + }); + + it('keeps the Market style surface intact', () => { + const [first] = validateSnapshot( + snapshot([ + { + ...marketRow(), + style: { title: { token: '$headingSm' }, changeWidth: 80 }, + } as MarketRow, + ]) + ).rows; + expect((first as MarketRow).style?.title).toEqual({ + fontSize: 16, + lineHeight: 24, + fontWeight: 'medium', + }); + expect((first as MarketRow).style?.changeWidth).toBe(80); + }); +}); diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index a095682e..bfa396db 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -34,7 +34,25 @@ export type BadgeModel = Readonly<{ tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; }>; -export type MarketTextStyle = Readonly<{ +/** + * Named typography steps, shared with the application's design scale. Resolved + * to fontSize/lineHeight/fontWeight in JavaScript before the snapshot is + * serialized, so no native renderer needs to know the vocabulary. + * See docs/STYLE_SPEC.md §3.2. + */ +export type NativeListTypographyToken = + | '$headingXl' + | '$headingLg' + | '$headingMd' + | '$headingSm' + | '$headingXs' + | '$bodyLg' + | '$bodyMd' + | '$bodySm' + | '$bodyXs'; + +export type NativeListTextStyle = Readonly<{ + token?: NativeListTypographyToken; fontSize?: number; fontWeight?: 'regular' | 'medium' | 'semibold' | 'bold'; color?: string; @@ -43,7 +61,10 @@ export type MarketTextStyle = Readonly<{ alignment?: 'start' | 'center' | 'end'; }>; -export type MarketImageStyle = Readonly<{ +/** Retained alias: Market shipped this name before the style surface was shared. */ +export type MarketTextStyle = NativeListTextStyle; + +export type NativeListImageStyle = Readonly<{ width?: number; height?: number; shape?: 'circle' | 'rounded' | 'square'; @@ -51,29 +72,122 @@ export type MarketImageStyle = Readonly<{ contentFit?: ImageContentFit; }>; -export type MarketRowStyle = Readonly<{ +export type MarketImageStyle = NativeListImageStyle; + +/** Box metrics every template shares. Bounds live in docs/STYLE_SPEC.md §3.3. */ +export type RowBoxStyle = Readonly<{ horizontalPadding?: number; verticalPadding?: number; leadingGap?: number; /** Space between title and subtitle; 0 by default, bounded to 0..16. */ lineGap?: number; titleBadgeGap?: number; - /** OneKey patch: keep badges next to the intrinsic title width. */ - titleBadgeLayout?: 'inline'; trailingGap?: number; - /** OneKey patch: preserve separate Market content and subtitle insets. */ - contentTrailingGap?: number; - subtitleTrailingPadding?: number; - image?: MarketImageStyle; - title?: MarketTextStyle; - subtitle?: MarketTextStyle; - price?: MarketTextStyle; - change?: MarketTextStyle; - changeWidth?: number; - changeHeight?: number; - changeCornerRadius?: number; + image?: NativeListImageStyle; }>; +/** + * A style key names the model field it modifies, never the view that carries + * it. The view pool is shared and the mapping is not one to one: metricCard + * renders `value` through the same label identity uses for `title`, and the + * status view carries rail.status, activity.status, message.time and + * metricCard.trend. See docs/STYLE_SPEC.md §4. + */ +export type IdentityRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + tertiary?: NativeListTextStyle; + badge?: NativeListTextStyle; + value?: NativeListTextStyle; + valueSecondary?: NativeListTextStyle; + }>; + +export type WalletGroupRowStyle = RowBoxStyle; + +export type RailRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + badge?: NativeListTextStyle; + status?: NativeListTextStyle; + }>; + +export type ActivityRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + description?: NativeListTextStyle; + status?: NativeListTextStyle; + primaryAmount?: NativeListTextStyle; + secondaryAmount?: NativeListTextStyle; + }>; + +export type MessageRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + body?: NativeListTextStyle; + time?: NativeListTextStyle; + }>; + +export type DataRowStyle = RowBoxStyle & + Readonly<{ + columns?: NativeListTextStyle; + columnSecondary?: NativeListTextStyle; + index?: NativeListTextStyle; + }>; + +export type MarketRowStyle = RowBoxStyle & + Readonly<{ + /** OneKey patch: keep badges next to the intrinsic title width. */ + titleBadgeLayout?: 'inline'; + /** OneKey patch: preserve separate Market content and subtitle insets. */ + contentTrailingGap?: number; + subtitleTrailingPadding?: number; + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + price?: NativeListTextStyle; + change?: NativeListTextStyle; + changeWidth?: number; + changeHeight?: number; + changeCornerRadius?: number; + }>; + +export type MediaTileRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + badge?: NativeListTextStyle; + }>; + +export type MetricCardRowStyle = RowBoxStyle & + Readonly<{ + /** The small label. Carried by the subtitle view on every platform. */ + title?: NativeListTextStyle; + /** The large number. Carried by the title view on every platform. */ + value?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + trend?: NativeListTextStyle; + }>; + +export type SectionHeaderRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + value?: NativeListTextStyle; + }>; + +export type ActionRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + value?: NativeListTextStyle; + }>; + +export type SystemRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + message?: NativeListTextStyle; + actionText?: NativeListTextStyle; + }>; + export type MarketBadgeModel = Readonly<{ key: string; text?: string; @@ -273,6 +387,7 @@ export type IdentityRow = RowBase & badges?: readonly BadgeModel[]; trailing?: readonly TrailingAccessory[]; draggable?: boolean; + style?: IdentityRowStyle; }>; export type WalletGroupRow = RowBase & @@ -281,6 +396,7 @@ export type WalletGroupRow = RowBase & parent: IdentityRow; children: readonly IdentityRow[]; draggable?: boolean; + style?: WalletGroupRowStyle; }>; export type RailRow = RowBase & @@ -291,6 +407,7 @@ export type RailRow = RowBase & status?: 'none' | 'online' | 'warning' | 'error'; badge?: BadgeModel; draggable?: boolean; + style?: RailRowStyle; }>; export type ActivityRow = RowBase & @@ -304,6 +421,7 @@ export type ActivityRow = RowBase & primaryAmount?: string; secondaryAmount?: string; footerActions?: readonly FooterAction[]; + style?: ActivityRowStyle; }>; export type MessageRow = RowBase & @@ -316,6 +434,7 @@ export type MessageRow = RowBase & bodyLines?: 1 | 2 | 3; time: string; thumbnail?: ImageSource; + style?: MessageRowStyle; }>; export type DataColumn = Readonly<{ @@ -340,6 +459,7 @@ export type DataRow = RowBase & badges?: readonly BadgeModel[]; favorite?: boolean; favoriteActive?: boolean; + style?: DataRowStyle; }>; /** Native Market quote row. All values are already localized/formatted strings. */ @@ -381,6 +501,7 @@ export type MediaTileRow = RowBase & subtitle?: string; badge?: BadgeModel; closeActionKey?: string; + style?: MediaTileRowStyle; }>; /** Compact KPI card for dashboards and grids. */ @@ -405,6 +526,7 @@ export type MetricCardRow = RowBase & badge?: BadgeModel; metrics?: readonly MetricValue[]; progress?: number; + style?: MetricCardRowStyle; }>; export type SectionHeaderRow = RowBase & @@ -427,6 +549,7 @@ export type SectionHeaderRow = RowBase & titleIcon?: Extract; valueIcon?: Extract; checkbox?: Extract; + style?: SectionHeaderRowStyle; }>; export type ActionRow = RowBase & @@ -439,9 +562,11 @@ export type ActionRow = RowBase & icon?: Extract; checkbox?: Extract; trailing?: readonly TrailingAccessory[]; + style?: ActionRowStyle; }>; export type SystemRow = RowBase & + Readonly<{ style?: SystemRowStyle }> & ( | Readonly<{ type: 'system'; @@ -575,7 +700,8 @@ type CommonPatchFields = | 'heightRounding' | 'opacity' | 'pressDisabled' - | 'accessibilityLabel'; + | 'accessibilityLabel' + | 'style'; export type RowPatch = | Readonly<{ diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index bd988bd3..214ae76c 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -5,8 +5,10 @@ import type { LeadingVisual, MarketRow, MarketRowStyle, - MarketTextStyle, NativeListSnapshot, + NativeListTextStyle, + NativeListTypographyToken, + RowBoxStyle, RowModel, RowPatch, WalletGroupRow, @@ -127,11 +129,100 @@ function assertBoundedStyleNumber( } } -function assertMarketTextStyle( - style: MarketTextStyle | undefined, +/** + * The named typography scale from docs/STYLE_SPEC.md §3.2. Tokens are resolved + * to numbers here, before serialization, so the iOS, Android, and Web renderers + * never learn the vocabulary and cannot drift from it. + */ +const TYPOGRAPHY_TOKENS: Readonly< + Record< + NativeListTypographyToken, + Readonly<{ + fontSize: number; + lineHeight: number; + fontWeight: NonNullable; + }> + > +> = { + $headingXl: { fontSize: 24, lineHeight: 32, fontWeight: 'semibold' }, + $headingLg: { fontSize: 20, lineHeight: 28, fontWeight: 'semibold' }, + $headingMd: { fontSize: 18, lineHeight: 24, fontWeight: 'semibold' }, + $headingSm: { fontSize: 16, lineHeight: 24, fontWeight: 'medium' }, + $headingXs: { fontSize: 14, lineHeight: 20, fontWeight: 'semibold' }, + $bodyLg: { fontSize: 16, lineHeight: 24, fontWeight: 'regular' }, + $bodyMd: { fontSize: 14, lineHeight: 20, fontWeight: 'regular' }, + $bodySm: { fontSize: 12, lineHeight: 16, fontWeight: 'regular' }, + $bodyXs: { fontSize: 11, lineHeight: 16, fontWeight: 'regular' }, +}; + +const BOX_STYLE_KEYS: readonly string[] = [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'titleBadgeGap', + 'trailingGap', + 'image', +]; + +/** Style keys name model fields, never views. See docs/STYLE_SPEC.md §4. */ +const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly< + Record +> = { + identity: [ + 'title', + 'subtitle', + 'tertiary', + 'badge', + 'value', + 'valueSecondary', + ], + walletGroup: [], + rail: ['title', 'badge', 'status'], + activity: [ + 'title', + 'description', + 'status', + 'primaryAmount', + 'secondaryAmount', + ], + message: ['title', 'body', 'time'], + dataRow: ['columns', 'columnSecondary', 'index'], + market: ['title', 'subtitle', 'price', 'change'], + mediaTile: ['title', 'subtitle', 'badge'], + metricCard: ['title', 'value', 'subtitle', 'trend'], + sectionHeader: ['title', 'subtitle', 'value'], + action: ['title', 'value'], + system: ['title', 'message', 'actionText'], +}; + +const EXTRA_STYLE_KEYS_BY_ROW_TYPE: Readonly< + Partial> +> = { + market: [ + 'titleBadgeLayout', + 'contentTrailingGap', + 'subtitleTrailingPadding', + 'changeWidth', + 'changeHeight', + 'changeCornerRadius', + ], +}; + +function assertTextStyle( + style: NativeListTextStyle | undefined, path: string ): void { if (!style) return; + if ( + style.token !== undefined && + !Object.prototype.hasOwnProperty.call(TYPOGRAPHY_TOKENS, style.token) + ) { + fail( + `${path}.token`, + `must be one of ${Object.keys(TYPOGRAPHY_TOKENS).join(', ')}` + ); + } assertBoundedStyleNumber(style.fontSize, `${path}.fontSize`, 8, 48); assertBoundedStyleNumber(style.lineHeight, `${path}.lineHeight`, 8, 64); if ( @@ -151,38 +242,17 @@ function assertMarketTextStyle( } } -function assertMarketStyle( - style: MarketRowStyle | undefined, - path: string -): void { - if (!style) return; +function assertBoxStyle(style: RowBoxStyle, path: string): void { for (const field of [ 'horizontalPadding', 'verticalPadding', 'leadingGap', 'titleBadgeGap', 'trailingGap', - 'contentTrailingGap', - 'subtitleTrailingPadding', ] as const) { assertBoundedStyleNumber(style[field], `${path}.${field}`, 0, 64); } - // OneKey patch: validate the opt-in Market badge layout. - if ( - style.titleBadgeLayout !== undefined && - style.titleBadgeLayout !== 'inline' - ) { - fail(`${path}.titleBadgeLayout`, 'must be inline when provided'); - } assertBoundedStyleNumber(style.lineGap, `${path}.lineGap`, 0, 16); - assertBoundedStyleNumber(style.changeWidth, `${path}.changeWidth`, 1, 160); - assertBoundedStyleNumber(style.changeHeight, `${path}.changeHeight`, 1, 160); - assertBoundedStyleNumber( - style.changeCornerRadius, - `${path}.changeCornerRadius`, - 0, - 80 - ); if (style.image) { assertBoundedStyleNumber(style.image.width, `${path}.image.width`, 1, 160); assertBoundedStyleNumber( @@ -208,10 +278,140 @@ function assertMarketStyle( ); } } - assertMarketTextStyle(style.title, `${path}.title`); - assertMarketTextStyle(style.subtitle, `${path}.subtitle`); - assertMarketTextStyle(style.price, `${path}.price`); - assertMarketTextStyle(style.change, `${path}.change`); +} + +function assertMarketStyle( + style: MarketRowStyle | undefined, + path: string +): void { + if (!style) return; + assertBoxStyle(style, path); + for (const field of [ + 'contentTrailingGap', + 'subtitleTrailingPadding', + ] as const) { + assertBoundedStyleNumber(style[field], `${path}.${field}`, 0, 64); + } + // OneKey patch: validate the opt-in Market badge layout. + if ( + style.titleBadgeLayout !== undefined && + style.titleBadgeLayout !== 'inline' + ) { + fail(`${path}.titleBadgeLayout`, 'must be inline when provided'); + } + assertBoundedStyleNumber(style.changeWidth, `${path}.changeWidth`, 1, 160); + assertBoundedStyleNumber(style.changeHeight, `${path}.changeHeight`, 1, 160); + assertBoundedStyleNumber( + style.changeCornerRadius, + `${path}.changeCornerRadius`, + 0, + 80 + ); + assertTextStyle(style.title, `${path}.title`); + assertTextStyle(style.subtitle, `${path}.subtitle`); + assertTextStyle(style.price, `${path}.price`); + assertTextStyle(style.change, `${path}.change`); +} + +/** + * Rejects keys the template does not declare, so a style written for one + * template cannot reach a shared view through another. See docs/STYLE_SPEC.md + * §7 rule 1. + */ +function assertRowStyle(row: RowModel, path: string): void { + const style = (row as { style?: unknown }).style; + if (style === undefined) return; + if (typeof style !== 'object' || style === null || Array.isArray(style)) { + fail(`${path}.style`, 'must be an object'); + } + const textKeys = TEXT_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []; + const allowed = new Set([ + ...BOX_STYLE_KEYS, + ...textKeys, + ...(EXTRA_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []), + ]); + Object.keys(style as Record).forEach((key) => { + if (!allowed.has(key)) { + fail( + `${path}.style.${key}`, + `is not a style key of the "${row.type}" template` + ); + } + }); + // Market keeps its own richer assertions, driven from assertMarketRow. + if (row.type === 'market') return; + assertBoxStyle(style as RowBoxStyle, `${path}.style`); + const slots = style as Record; + textKeys.forEach((key) => + assertTextStyle(slots[key], `${path}.style.${key}`) + ); +} + +/** Token resolution is idempotent: a resolved style carries no token. */ +function resolveTextStyle( + style: NativeListTextStyle | undefined +): NativeListTextStyle | undefined { + if (!style?.token) return style; + const { token, ...overrides } = style; + return { ...TYPOGRAPHY_TOKENS[token], ...overrides }; +} + +function resolveStyleTokens( + style: Record, + rowType: RowModel['type'] +): Record { + const textKeys = TEXT_STYLE_KEYS_BY_ROW_TYPE[rowType] ?? []; + let changed = false; + const next: Record = { ...style }; + textKeys.forEach((key) => { + const slot = style[key] as NativeListTextStyle | undefined; + const resolved = resolveTextStyle(slot); + if (resolved !== slot) { + next[key] = resolved; + changed = true; + } + }); + return changed ? next : style; +} + +function normalizeRowStyles(row: T): T { + // Narrowing a generic does not reach the walletGroup members; go through the + // union type instead. + const model: RowModel = row; + const style = (row as { style?: Record }).style; + const nextStyle = style ? resolveStyleTokens(style, row.type) : style; + const prefix = model.type === 'market' ? model.subtitlePrefix : undefined; + const nextPrefixStyle = resolveTextStyle(prefix?.style); + let nextParent: IdentityRow | undefined; + let nextChildren: readonly IdentityRow[] | undefined; + if (model.type === 'walletGroup') { + const parent = normalizeRowStyles(model.parent); + const children = model.children.map(normalizeRowStyles); + if ( + parent !== model.parent || + children.some((child, index) => child !== model.children[index]) + ) { + nextParent = parent; + nextChildren = children; + } + } + if ( + nextStyle === style && + nextPrefixStyle === prefix?.style && + nextParent === undefined + ) { + return row; + } + const next: Record = { ...row }; + if (nextStyle !== style) next.style = nextStyle; + if (prefix && nextPrefixStyle !== prefix.style) { + next.subtitlePrefix = { ...prefix, style: nextPrefixStyle }; + } + if (nextParent !== undefined) { + next.parent = nextParent; + next.children = nextChildren; + } + return next as T; } function assertMarketRow(row: MarketRow, path: string): void { @@ -235,10 +435,7 @@ function assertMarketRow(row: MarketRow, path: string): void { 1, 320 ); - assertMarketTextStyle( - row.subtitlePrefix.style, - `${path}.subtitlePrefix.style` - ); + assertTextStyle(row.subtitlePrefix.style, `${path}.subtitlePrefix.style`); } assertText(row.price, `${path}.price`); assertText(row.change.text, `${path}.change.text`); @@ -286,7 +483,7 @@ function assertMarketRow(row: MarketRow, path: string): void { badgeKeys.add(badge.key); assertText(badge.text, `${badgePath}.text`); // OneKey patch: share typography bounds with Market text styles. - assertMarketTextStyle(badge.style, `${badgePath}.style`); + assertTextStyle(badge.style, `${badgePath}.style`); assertBoundedStyleNumber( badge.style?.height, `${badgePath}.style.height`, @@ -597,6 +794,7 @@ function assertRow( if (!row.groupId && row.groupPosition) { fail(`${path}.groupId`, 'is required when groupPosition is present'); } + assertRowStyle(row, path); assertVisual(row, path); switch (row.type) { @@ -1016,11 +1214,44 @@ export function validateSnapshot( 'supports at most one key in single mode' ); } - return snapshot; + return normalizeSnapshotStyles(snapshot); +} + +/** + * Resolves typography tokens to numbers. Returns the original snapshot when + * nothing needs resolving, so the common no-style path keeps object identity. + */ +function normalizeSnapshotStyles( + snapshot: NativeListSnapshot +): NativeListSnapshot { + let changed = false; + const rows = snapshot.rows.map((row) => { + const next = normalizeRowStyles(row); + if (next !== row) changed = true; + return next; + }); + const emptyState = snapshot.emptyState + ? normalizeRowStyles(snapshot.emptyState) + : snapshot.emptyState; + const fixedFooter = snapshot.fixedFooter + ? normalizeRowStyles(snapshot.fixedFooter) + : snapshot.fixedFooter; + if ( + !changed && + emptyState === snapshot.emptyState && + fixedFooter === snapshot.fixedFooter + ) { + return snapshot; + } + return { ...snapshot, rows, emptyState, fixedFooter }; } function assertPatchChanges(patch: RowPatch, index: number): void { const path = `patches[${index}].changes`; + const patchedStyle = (patch.changes as { style?: unknown }).style; + if (patchedStyle !== undefined) { + assertRowStyle({ type: patch.type, style: patchedStyle } as RowModel, path); + } // OneKey patch: partial balance updates retain a valid, current accessibility label. if ('accessibilityLabel' in patch.changes) { assertText(patch.changes.accessibilityLabel, `${path}.accessibilityLabel`); @@ -1236,7 +1467,26 @@ export function validatePatches( } assertPatchChanges(patch, index); }); - return patches; + return normalizePatchStyles(patches); +} + +/** Patches reach the native side without passing through a snapshot. */ +function normalizePatchStyles( + patches: readonly RowPatch[] +): readonly RowPatch[] { + let changed = false; + const next = patches.map((patch) => { + const style = (patch.changes as { style?: Record }).style; + if (!style) return patch; + const resolved = resolveStyleTokens(style, patch.type); + if (resolved === style) return patch; + changed = true; + return { + ...patch, + changes: { ...patch.changes, style: resolved }, + } as RowPatch; + }); + return changed ? next : patches; } export function applyRowPatches( diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index d893cba3..5e89b68f 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -9,8 +9,10 @@ import type { NativeListActionAnchor, NativeListActionSource, NativeListTheme, + NativeListTextStyle, ReorderEvent, RowActionEvent, + RowBoxStyle, RowModel, RowPatch, SelectionDeltaEvent, @@ -1754,11 +1756,9 @@ function createBadge( context: RenderContext, badge: Readonly<{ text: string; tone?: string }> ): HTMLElement { - const element = createElement( - context.document, - 'span', - 'ok-native-list-badge', - badge.text + const element = tagSlot( + createElement(context.document, 'span', 'ok-native-list-badge', badge.text), + 'badge' ); setData(element, 'tone', badge.tone); return element; @@ -2070,26 +2070,42 @@ function appendAccessories( ) { setData(container, 'nativeListAccountControl', 'createAddress'); } - accessories.forEach((accessory, slot) => - container.appendChild(createAccessory(context, rowKey, accessory, slot)) - ); + accessories.forEach((accessory, slot) => { + const element = createAccessory(context, rowKey, accessory, slot); + if (accessory.kind === 'value' || accessory.kind === 'valuePair') { + tagSlot(element, slot === 0 ? 'value' : 'valueSecondary'); + } + container.appendChild(element); + }); parent.appendChild(container); } +/** + * Marks the element that renders one model field. Style keys name model fields, + * not views, and the view pool is shared. See docs/STYLE_SPEC.md §4. + */ +function tagSlot(element: T, slot: string): T { + element.dataset.nlSlot = slot; + return element; +} + function createTextColumn( context: RenderContext, title: string, subtitle?: string, tertiary?: string, tertiaryTone?: 'secondary' | 'info', - badges?: readonly Readonly<{ text: string; tone?: string }>[] + badges?: readonly Readonly<{ text: string; tone?: string }>[], + slots: Readonly<{ title: string; subtitle: string; tertiary: string }> = { + title: 'title', + subtitle: 'subtitle', + tertiary: 'tertiary', + } ): HTMLElement { const column = createElement(context.document, 'span', 'ok-native-list-flex'); - const titleLine = createElement( - context.document, - 'span', - 'ok-native-list-title', - title + const titleLine = tagSlot( + createElement(context.document, 'span', 'ok-native-list-title', title), + slots.title ); if (badges?.length) { const badgeLine = createElement( @@ -2105,21 +2121,27 @@ function createTextColumn( column.appendChild(titleLine); if (subtitle) column.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - subtitle + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + subtitle + ), + slots.subtitle ) ); if (tertiary) { - const element = createElement( - context.document, - 'span', - tertiaryTone === 'info' - ? 'ok-native-list-secondary ok-native-list-info' - : 'ok-native-list-secondary ok-native-list-tertiary', - tertiary + const element = tagSlot( + createElement( + context.document, + 'span', + tertiaryTone === 'info' + ? 'ok-native-list-secondary ok-native-list-info' + : 'ok-native-list-secondary ok-native-list-tertiary', + tertiary + ), + slots.tertiary ); column.appendChild(element); } @@ -2230,13 +2252,16 @@ function createSectionHeader( } body.appendChild(column); if (row.value) { - const value = createElement( - context.document, - row.valueActionKey ? 'button' : 'span', - row.valueActionKey - ? 'ok-native-list-action-button ok-native-list-section-value' - : 'ok-native-list-value ok-native-list-section-value', - row.value + const value = tagSlot( + createElement( + context.document, + row.valueActionKey ? 'button' : 'span', + row.valueActionKey + ? 'ok-native-list-action-button ok-native-list-section-value' + : 'ok-native-list-value ok-native-list-section-value', + row.value + ), + 'value' ); applyValueSegments(value, row.valueSegments); if (row.presentation === 'networkSelector' && row.height !== undefined) { @@ -2292,11 +2317,14 @@ function createActionRow( .join(' ') ); if (row.icon) body.appendChild(createVisual(context, row.icon)!); - const title = createElement( - context.document, - 'span', - 'ok-native-list-action-title', - row.title + const title = tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-action-title', + row.title + ), + 'title' ); setData(title, 'tone', row.tone); if (row.presentation === 'accountSelector' && row.icon) @@ -2389,7 +2417,10 @@ function createSystemRow( if (row.variant === 'noMatch') { body.style.justifyContent = 'center'; body.style.padding = '32px'; - const message = createElement(context.document, 'span', '', row.message); + const message = tagSlot( + createElement(context.document, 'span', '', row.message), + 'message' + ); message.style.fontSize = '16px'; message.style.lineHeight = '24px'; body.appendChild(message); @@ -2414,19 +2445,25 @@ function createSystemRow( body.classList.add('ok-native-list-warning'); body.style.borderColor = row.borderColor ?? 'var(--nl-separator)'; body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-warning-title', - row.title + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-warning-title', + row.title + ), + 'title' ) ); body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-warning-message', - row.message + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-warning-message', + row.message + ), + 'message' ) ); return body; @@ -2441,11 +2478,14 @@ function createSystemRow( : row.message ?? (row.variant === 'end' ? 'End' : ''); if (message) body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - message + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + message + ), + 'message' ) ); return body; @@ -2463,20 +2503,26 @@ function createRailRow( const visual = createVisual(context, row.visual); if (visual) body.appendChild(visual); body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-rail-title', - row.title + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-rail-title', + row.title + ), + 'title' ) ); if (row.status && row.status !== 'none') body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.status + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + row.status + ), + 'status' ) ); if (row.badge) body.appendChild(createBadge(context, row.badge)); @@ -2516,11 +2562,14 @@ function createMediaRow( 'ok-native-list-media-subtitle-row' ); subtitleLine.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-media-subtitle', - row.subtitle || '-' + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-media-subtitle', + row.subtitle || '-' + ), + 'subtitle' ) ); if (row.networkImage) { @@ -2533,11 +2582,14 @@ function createMediaRow( } metadata.appendChild(subtitleLine); metadata.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-media-title', - row.title + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-media-title', + row.title + ), + 'title' ) ); if (row.badge) metadata.appendChild(createBadge(context, row.badge)); @@ -2680,27 +2732,36 @@ function createMetricRow( if (visual) body.appendChild(visual); } body.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.title + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-secondary', + row.title + ), + 'title' ) ); body.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-metric-value', - row.value + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-metric-value', + row.value + ), + 'value' ) ); if (row.trend) { - const trend = createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.trend + const trend = tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-secondary', + row.trend + ), + 'trend' ); trend.style.color = row.trendTone === 'positive' @@ -2712,11 +2773,14 @@ function createMetricRow( } if (row.subtitle) body.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.subtitle + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-secondary', + row.subtitle + ), + 'subtitle' ) ); if (row.badge) body.appendChild(createBadge(context, row.badge)); @@ -2736,11 +2800,14 @@ function createDataRow( body.appendChild(createCheckbox(context, row.key, row.checkbox)); if (row.index !== undefined) body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-index', - String(row.index) + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-index', + String(row.index) + ), + 'index' ) ); if (row.favorite) { @@ -2765,10 +2832,9 @@ function createDataRow( ); cell.style.flex = String(column.weight ?? 1); setData(cell, 'align', column.alignment ?? 'start'); - const primary = createElement( - context.document, - 'span', - 'ok-native-list-data-primary' + const primary = tagSlot( + createElement(context.document, 'span', 'ok-native-list-data-primary'), + 'columns' ); primary.style.color = toneColor(column.tone, 'primary'); if (column.secondaryLeadingText) @@ -2790,11 +2856,14 @@ function createDataRow( } cell.appendChild(primary); if (column.secondaryText) { - const secondary = createElement( - context.document, - 'span', - 'ok-native-list-secondary', - column.secondaryText + const secondary = tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + column.secondaryText + ), + 'columnSecondary' ); secondary.style.color = toneColor(column.secondaryTone, 'secondary'); cell.appendChild(secondary); @@ -2907,7 +2976,17 @@ function createIdentityActivityOrMessageRow( row.type === 'identity' ? row.tertiaryTone : undefined, row.type === 'identity' && presentation !== 'walletSidebar' ? row.badges - : undefined + : undefined, + { + title: 'title', + subtitle: + row.type === 'activity' + ? 'description' + : row.type === 'message' + ? 'body' + : 'subtitle', + tertiary: 'tertiary', + } ); // OneKey patch: match existing search, subtitle fragments, and sidebar badges. if (row.type === 'identity') { @@ -3021,26 +3100,40 @@ function createIdentityActivityOrMessageRow( ); if (row.primaryAmount) amounts.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-value', - row.primaryAmount + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-value', + row.primaryAmount + ), + 'primaryAmount' ) ); if (row.secondaryAmount) amounts.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.secondaryAmount + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + row.secondaryAmount + ), + 'secondaryAmount' ) ); body.appendChild(amounts); } else if (row.type === 'message') { body.appendChild( - createElement(context.document, 'span', 'ok-native-list-time', row.time) + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-time', + row.time + ), + 'time' + ) ); if (row.thumbnail) { const thumbnail = createImage( @@ -3425,7 +3518,78 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement { return body; } -function createRowBody(context: RenderContext, row: RowModel): HTMLElement { +const ROW_BOX_STYLE_KEYS: ReadonlySet = new Set([ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'titleBadgeGap', + 'trailingGap', + 'image', +]); + +function applyTextStyleToSlot( + element: HTMLElement, + style: NativeListTextStyle +): void { + if (style.fontSize !== undefined) + element.style.fontSize = String(style.fontSize) + 'px'; + if (style.lineHeight !== undefined) + element.style.lineHeight = String(style.lineHeight) + 'px'; + if (style.fontWeight !== undefined) + element.style.fontWeight = String(marketFontWeight(style.fontWeight, 400)); + if (style.color !== undefined) element.style.color = style.color; + if (style.alignment !== undefined) element.style.textAlign = style.alignment; + if (style.lines !== undefined) { + element.style.removeProperty('-webkit-line-clamp'); + element.style.removeProperty('-webkit-box-orient'); + element.style.display = ''; + element.style.whiteSpace = style.lines === 2 ? 'normal' : 'nowrap'; + if (style.lines === 2) { + element.style.display = '-webkit-box'; + element.style.setProperty('-webkit-line-clamp', '2'); + element.style.setProperty('-webkit-box-orient', 'vertical'); + } + } +} + +/** + * Applies a row style once the template has been built. A style key names the + * model field it modifies and the element rendering that field carries + * `data-nl-slot`, because the view pool is shared across templates. Market + * keeps its own richer path. See docs/STYLE_SPEC.md §4 and §8. + */ +export function applyRowStyle(body: HTMLElement, row: RowModel): void { + if (row.type === 'market') return; + const style = (row as { style?: Record }).style; + if (!style) return; + const box = style as RowBoxStyle; + if (box.horizontalPadding !== undefined) + body.style.paddingInline = String(box.horizontalPadding) + 'px'; + if (box.verticalPadding !== undefined) + body.style.paddingBlock = String(box.verticalPadding) + 'px'; + if (box.lineGap !== undefined) { + const lineGap = String(box.lineGap) + 'px'; + body + .querySelectorAll('.ok-native-list-flex') + .forEach((column) => { + column.style.rowGap = lineGap; + }); + } + Object.keys(style).forEach((key) => { + if (ROW_BOX_STYLE_KEYS.has(key)) return; + const slotStyle = style[key] as NativeListTextStyle | undefined; + if (!slotStyle) return; + body + .querySelectorAll('[data-nl-slot="' + key + '"]') + .forEach((element) => applyTextStyleToSlot(element, slotStyle)); + }); +} + +export function createRowBody( + context: RenderContext, + row: RowModel +): HTMLElement { switch (row.type) { case 'walletGroup': return createWalletGroupRow(context, row); @@ -4218,6 +4382,7 @@ export class NativeListWebEngine { }; const body = createRowBody(context, row); applySelectorTabularNumbers(body, row); + applyRowStyle(body, row); // OneKey patch: explicit selector fields preserve original page geometry. element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor;