Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 23 additions & 4 deletions native-views/react-native-native-list/docs/STYLE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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="<field>"`, 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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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('<!doctype html><body></body>').window;
const body = createRowBody(
{
document,
snapshot: {
schemaVersion: 1,
generation: 1,
layout: { kind: 'linear' },
rows: [styledRow],
},
selectedKeys: new Set<string>(),
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<HTMLElement>('[data-nl-slot="title"]');
const value = body.querySelector<HTMLElement>('[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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading