Skip to content

feat(native-list): unify template spec and cross-platform row styles - #128

Draft
huhuanming wants to merge 42 commits into
mainfrom
codex/native-list-spec
Draft

huhuanming wants to merge 42 commits into
mainfrom
codex/native-list-spec

Conversation

@huhuanming

@huhuanming huhuanming commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Summary

NativeList accepted local styles that some renderers ignored or applied to the wrong fields. This PR consolidates the five specification PRs, implements a shared configurable row-style contract, completes the Message pilot, and starts stage 3 with a dedicated Rail renderer on all three platforms.

  • Define all 12 row templates with semantic fields, variants and diagrams. Templates own structure; row.style customizes existing fields. Header/footer placement, sections, scrolling and indexed bar remain common list capabilities.
  • Unify configurable text controls (font/color/line height, 1–3 lines, tail/clip, horizontal/vertical alignment and optical offset), primary image styles, semantic gaps and container appearance. Height precedence is style.container.heightrow.height → template/layout measurement. Clearing styles restores the original model/default behavior.
  • Apply styles to the correct fields, including repeated badges/values, data-column secondary text and Market quote updates. Preserve rich-text metrics, selection/summary updates and reuse restoration. Android sticky headers use normal row rendering; container borders paint above wallet members.
  • Record the complete implicit-key/default/update inventory in docs/MIGRATION_BASELINE.md, including each rule's platform scope and migration disposition. Keep existing compatibility policies explicit until their callers migrate.
  • Register Message separately from the legacy family on all three platforms. iOS/Android allocate lightweight Message hosts and renderer-owned text/image views; Web keeps a persistent body, text nodes and unchanged image elements. Resolved styles drive binding and measurement. Image slots own request epochs, bounded retries and stale-callback cancellation independently of action-anchor epochs. Remove legacy Message binders and global style-slot mappings.
  • Migrate Rail to renderer-owned title/badge/status/image views on all platforms, sharing lightweight native host lifecycle with Message. Web retains its body and unchanged image nodes. Remove legacy Rail branches and resolve horizontal sizing from current styles while preserving default width allowances. Fix Android text styles without offsetY producing NaN and invisible text.
  • Correct Message intrinsic measurement to use resolved view metrics; iOS uses actual allocated column width, including patch size comparisons, and Web corrects its conservative estimate using the rendered DOM. Explicit heights retain their precedence. Preserve existing default drawing and later templates' partial-update paths.
  • Include plain/styled examples for every template and preserve compatible pooling through same-key template changes, remove/reinsert and scrolling.

Consolidates the now-closed #107, #109, #110, #111 and #112. Main is merged through 52849b86f.

Boundaries

Integrators own content fitting and overflow; there is no automatic shrinking or layout correction. Styles cannot replace template structure. No arbitrary React header/footer slot or public renderer/plugin API is added. Existing unstyled platform differences are inventoried separately from configurable-property parity. Image geometry/content-fit changes may require the image module to decode/request again; text-only updates retain unchanged effective requests.

The six-stage plan in docs/SPEC.md marks stages 1 and 2 complete. Stage 3 is in progress: Rail is complete (1/7); mediaTile, action, system, activity, dataRow and metricCard remain. Stages 4–6 are not started. Those templates still use legacy hosts. No allocation/scrolling-performance benchmark is claimed.

Validation

  • Package typecheck and 153 tests in five suites pass; lint has zero errors and 21 existing warnings. Tests cover validation, height precedence/reset, semantic isolation, text clipping, rich-text/quote updates, persistent Message text/image identity and compatible host pooling.
  • Both example toggle states validate: 46 rows across 12 templates. Example lint and focused example/composition TypeScript checks pass.
  • Android full validation APK build, compileDebugKotlin and testDebugUnitTest pass (seven tests, three suites). The isolated app runs the real NativeList/Image/Logger/Skeleton/Nitro packages on a dedicated external-drive API 36 emulator.
  • Full iOS Debug app build/link and runtime acceptance pass on a dedicated iPhone 17 Pro / iOS 26.5 simulator, with its data volume on the external drive and the same real packages.
  • Both native runtimes check Message style/height set-change-clear, typography, missing fields, intrinsic/grid measurement, same-key replacement, HTTP/local images, text-only image retention, failure recovery, delayed-source removal, enabled/disabled row actions, container appearance, 41-row mixed swaps, empty/repopulation and scrolling with the fixed footer. Explicit height cycles remain iOS 136 → 192 → 136pt and Android 122 → 192 → 122px on the narrow device.
  • Chrome checks desktop and 390px RTL rendering, corresponding Message lifecycle cases, image-node retention and DOM-corrected intrinsic height. Web explicit heights remain 136 → 192 → 136px. Browser errors and native crash/constraint logs are inspected; screenshots, native accessibility trees, request logs and an iOS recording are retained outside the repository.
  • Rail runtime acceptance passes on all three platforms: style set/clear, row-height restoration, local image/corner-badge geometry, text/image replacement, badge/status removal, horizontal widths, 41-row mixed family swaps, empty/repopulate, scrolling and fixed footer. Enabled/disabled row actions and long-press drag reorder pass; iOS reorder is verified with a 600ms XCTest hold. Chrome also checks 390px RTL. Native Rail text bounds preserve the captured legacy baseline.
  • git diff --check passes.

Full native acceptance of later templates (including long/CJK/RTL content, pinned-header interactions and WalletGroup reorder) remains part of their migration. Earlier sampled gallery checks do not replace that acceptance. This PR remains draft.

huhuanming and others added 9 commits September 22, 2026 14:53
Add docs/STYLE_SPEC.md as the style vocabulary for rows, section headers,
fixed footers, and empty states. The three renderers cannot constrain each
other, so the spec is authoritative by review plus the one boundary they
share: the serialized snapshot.

It records the design tokens aliased to the application's own token names,
the per-template style surface keyed by model field rather than by view
(metricCard renders its value through the title label, so view names
mis-target), list chrome, the template isolation rules, and a review
checklist.

Cross-platform divergences are registered, not changed: the three row-height
tables disagree (rail 40/28/40, sectionHeader summary 68/80/68), Android
draws sticky headers through a second renderer with its own typography,
section headers carrying a value or a checkbox silently lose stickiness on
Android, and source scale is computed list-wide.

Remove dead duplicated rules from WEB_LIST_CSS: four blocks were emitted
twice and a prefers-reduced-motion block three times, and every property of
the earlier copies was redeclared by the later ones, so removing them
changes no rendering. The two oddities that look like duplication are
load-bearing and are documented instead - the !important on the account
action row overrides an inline radius, and the market change chip's literal
colors do not equal their nearest tokens.

Also apply prettier to NativeList.web.test.ts, which was left unformatted by
d91262e and failed lint before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Both platforms gain applyRowStyle, running after the per-template binder so it is
the last writer. Android must run after applySize, which re-dispatches font size
and typeface by row type and would otherwise overwrite the style.

A style key names a model field, and the view pool is shared, so the same field
lands in a different view per template: metricCard draws its value through the
view identity uses for its title, and one status view carries rail.status,
activity.status, message.time and metricCard.trend. The mapping is
nativeListStyleSlot in NativeListModels.kt and styleSlot in NativeListCell.swift,
both following docs/STYLE_SPEC.md section 4. The Kotlin copy is unit-tested,
including a check that no template maps two style keys onto one view - a
collision would make one of them silently win.

Both platforms also gain resetRowStyle, running before the binder, because
neither reset path is complete (STYLE_SPEC section 7 rule 2). Android's
resetViews restores visibility, gravity, maxLines, layout params, background and
padding but not textSize, typeface or lineHeight. iOS's reset restores the fonts
of the shared labels but not those of the data, metric and media labels, nor any
text alignment. Without the reset a style would leak into the next row that
reuses the view. Both resets are guarded by a dirty flag, so an unstyled list
pays nothing.

On iOS the pass rebuilds the attributed string rather than assigning font and
textColor: the binders install an attributed string through setLineHeight, so a
plain font assignment would not take effect. The rebuilt line box centers font
metrics inside an explicit lineHeight, matching React Native and the Web engine.

Deliberately unchanged, and recorded in the spec's status table:

- Row heights. A styled row that grows still needs an explicit height.
- The list-wide source scale on Android. Making it per-row would move every
  selector list's metrics and needs device verification.
- dataRow columns in the table layout, whose text lives inside
  NativeListTableColumnView; the linear layout is covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The row style now exists on all three platforms but only the Web half has
automated coverage, so the native halves need something to check against. This
page is that artifact.

Every template is rendered twice - plain, then styled - behind a toggle. With
the toggle off the whole page must match the build from before the row style
existed, which is the regression check that matters most: unstyled rows must not
move by a pixel.

The rows deliberately cover what the field-to-view mapping is most likely to get
wrong. metricCard styles `value` and `title` separately, and the two must land on
the large number and the small label respectively even though they share views
with identity's title and subtitle. message styles `body` and `time`, which are
carried by the subtitle and status views. One identity row grows its text and
gives an explicit height, because heights are not derived from the style.

Also link STYLE_SPEC.md from DESIGN.md, so the architecture document points at
the style vocabulary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
snapshot.listStyle carries the list chrome that sits outside a row:
separator.inset, separator.color and groupCornerRadius, applied on Web, iOS
and Android.

The surface is deliberately small. It carries only what all three platforms can
honour, and validateSnapshot rejects any other key rather than accepting one
that some platform would quietly ignore. Pull to refresh is a system control on
both native platforms and only Web draws its own indicator; the section index
rail belongs to capabilities.sectionIndex and is three independent constant sets
today; the reorder preview and count badge are drawn with platform-specific
primitives, including a Canvas badge inside Android's dispatchDraw; and content
padding and item spacing already live on layout, so repeating them here would
give one value two homes. docs/STYLE_SPEC.md section 5 records all of that.

Every value is absent by default and each platform keeps its own number as the
fallback, so an untouched list renders exactly as before. Web needed care for
that: an inset separator keeps the transparent border-bottom so the row's height
does not change, and paints the visible line with an inset-inline-start overlay
so it also follows RTL, which the border could not.

Chrome lives outside the row payload, so a changed listStyle has to reach rows
whose own content is unchanged. Android routes it through the existing theme
rebind flag and iOS folds it into the themeChanged comparison and into both
snapshot fast-path guards.

The example page gains a listStyle section: a row with an inset separator and a
two-row grouped card, both driven by the snapshot rather than by the rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… boundaries

Document all 12 RowModel types with structural SVG diagrams, local style layout constraints, and shared container ownership for headers, footers, sections, scrolling and the indexed bar. Record source-verified implementation gaps from the consolidated PR stack.

Validation: 83 NativeList tests; package and focused example/document TypeScript checks; package and focused example lint; Swift syntax parsing; document link and SVG checks plus offline visual inspection. Native SDK builds and device acceptance remain pending.
@huhuanming huhuanming changed the title feat(native-list): consolidate template spec and scoped row styling feat(native-list): unify template spec and cross-platform row styles Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant