From cd2401ef96c4bb79b17ac258e12f78c5c889ebeb Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 16 Sep 2026 09:23:37 +0800 Subject: [PATCH] feat(native-list): add the per-template row style and apply it on Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every template except market hard-codes its typography in three renderers, so tuning one meant adding a presentation variant plus a branch per platform. Give each template a bounded style instead. 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 its value through the label identity uses for its title, and one status view carries rail.status, activity.status, message.time and metricCard.trend. So metricCard.style.value reaches the large number, and identity.style.title the title, through the same physical view. Typography can be a named step from the application's scale ({ token: '$bodyLg' }). Tokens resolve to numbers in validation.ts before the snapshot is serialized, so no native renderer learns the vocabulary and none can drift from it; an explicit fontSize alongside a token still wins. Resolution is idempotent and a snapshot with nothing to resolve is returned unchanged, so the common path keeps object identity. Isolation follows docs/STYLE_SPEC.md §7: style types are per template, and validateSnapshot and validatePatches reject a key the row type does not declare, so a style written for one template cannot reach a shared view through another. Styles carried by a patch are validated and resolved the same way, because patches reach the native side without passing through a snapshot. Web applies it in renderElement after createRowBody. The element rendering a field carries data-nl-slot, because .ok-native-list-secondary alone is the identity subtitle, the rail status, a metric label, a data column's secondary text and a system message. Text slots work for every template, along with horizontalPadding, verticalPadding and lineGap. leadingGap, trailingGap, titleBadgeGap and image stay validated but applied only on market: the others need a per-template default gap that cannot be read back from the DOM. Row heights are untouched. A styled row that grows still needs an explicit height, as recorded in docs/STYLE_SPEC.md §6.1. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 + .../docs/STYLE_SPEC.md | 27 +- .../src/__tests__/NativeList.web.test.ts | 83 ++++ .../src/__tests__/validation.test.ts | 90 ++++ .../react-native-native-list/src/models.ts | 160 ++++++- .../src/validation.ts | 322 ++++++++++++-- .../src/web/NativeListWebEngine.ts | 407 ++++++++++++------ 7 files changed, 914 insertions(+), 178 deletions(-) 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;