Skip to content

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

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

huhuanming wants to merge 62 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 common configurable row-style contract, and completes the Message pilot, all seven simple-template migrations, and Market/Identity/SectionHeader plus WalletGroup renderer migration on iOS, Android and Web.

  • Define all 12 row templates with fields, variants and diagrams. Templates own structure; row.style customizes existing slots. Header/footer, sections, scrolling and indexed bar remain list capabilities.
  • Unify 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 measurement/default. Clearing styles restores defaults.
  • Apply styles to the correct semantic fields, including repeated badges/values, DataRow secondary text and Market quote updates. Preserve selection/summary updates and reuse restoration.
  • Inventory implicit keys, defaults and special update rules in docs/MIGRATION_BASELINE.md. Audit consuming app-monorepo builders before removing key-based header/separator styling; retain the separately scoped Market pagination and DataRow badge-column policies.
  • Use a closed renderer registry for Message, Rail, MediaTile, Action, System, Activity, DataRow, MetricCard, Market, Identity, SectionHeader and WalletGroup. Native renderers own lightweight view trees and measurement; Web preserves renderer bodies and unchanged image elements. The eight pilot/simple templates no longer use their legacy binders or exclusive allocations. Complex rows use independent hosts; WalletGroup composes keyed Identity member hosts, recycles removed members immediately, and owns expanded/compact measurement. All twelve templates now have no legacy host, fallback registry or global style-slot map. Web measurement and compact previews are renderer-owned.
  • Share bounded image/accessory primitives and host appearance/event lifecycle. Image request epochs are independent of action anchors; unchanged requests survive text/style rebinding, while source/slot replacement invalidates callbacks and retries. Fixed footers use the same registry.
  • Preserve native table padding, zebra backgrounds, checkbox selection, Activity footer actions, System variants and all three MetricCard layouts. Metric image slots remain independent even when metric keys repeat. Message automatic sizing uses resolved content metrics; explicit heights retain priority.

Consolidates the closed #107, #109, #110, #111 and #112. Changes were committed incrementally by template.

Scope and remaining work

docs/SPEC.md marks stages 1 and 2 complete, and stage 3 complete (7/7): Rail, MediaTile, Action, System, Activity, DataRow, MetricCard. Stage 4 is also complete (3/3): Market, Identity and SectionHeader. Stage 5 is complete: WalletGroup and its keyed Identity members are migrated on all three platforms. Stage 6 is complete: obsolete hosts/maps/fallback pools are deleted; audited implicit header/separator styling is removed; containers use renderer lifecycle/measurement/preview contracts. All twelve template types now use dedicated renderers.

Integrators own overflow/content fitting; no automatic shrinking, new public renderer/plugin API or arbitrary React header/footer slot is added. Existing unstyled platform differences remain distinct from configurable-property parity. Composite MetricCard internals retain their documented fixed slots. No scrolling-performance benchmark is claimed. This PR remains draft.

Validation

The TypeScript, unit, native-build and simulator/browser checks below were run locally. GitHub currently exposes only the two Socket checks; no hosted native-build or TypeScript validation gate is configured. The EOF-only follow-up removes two blank lines and was checked against the full PR diff; it does not rerun the earlier native/device acceptance.

  • Package TypeScript check and 164 tests in five suites pass. Focused ESLint has zero errors (two existing shadowing warnings). Tests cover semantic style isolation, height/reset behavior, persistent body/image identity, column removal/restoration, content updates during styling, variant changes and incompatible-family reuse.
  • Android Debug APK build and five unit tests in two suites pass (two obsolete global-slot-map tests were removed with their implementation). The actual NativeList/Image/Logger/Skeleton/Nitro packages run on the task's external-drive API 36 emulator.
  • Full iOS Debug build/link and simulator acceptance pass on the dedicated external-drive iOS simulator.
  • All seven simple templates were exercised on iOS, Android and headed Chrome: default/styled/reset rendering, their relevant actions and selection, variant/content/image changes, empty/repopulation, scrolling and the shared fixed footer. Native default layouts were compared with pre-migration captures. MetricCard's standard/activity/performance layouts and repeated-key image slots were checked separately. DataRow includes linear/table switching and checkbox callbacks. Rail also covers horizontal sizing and drag reorder.
  • Stage 4 native/Web acceptance covers Market token/stock/perp, four Identity presentations and five header variants, style set/clear, quote and summary patches, badge/checkbox/value actions, native long-press window points, empty/repopulation, scrolling and the common fixed footer. A three-section fixture verifies header pinning and index behavior. Web also checks loaded image-node retention, narrow layouts and RTL.
  • Stage 5 native/Web acceptance covers member press/menu events and current anchors, selected/disabled state, member removal/reorder, style set/reset, same-key family replacement, empty/refill, scrolling and the shared footer. Actual child/parent drags reorder the group atomically; disabled/excluded members cannot start a drag. The 68-point preview and +1 badge exclude the add-wallet member. Drop restores both 274-point default and 340-point styled fixture heights. iOS also verifies a no-op long press; Android verifies touch cancellation; Web verifies Escape cancellation and a 390px viewport. Native WalletSidebar now binds optional trailing controls, and Web gates disabled member menu events. Callers remain responsible for sufficient member height.
  • Fix container issues found by section acceptance: iOS resolves pinned headers and active index from current layout positions during imperative jumps; Android measures and lays out the sticky overlay when a React Native parent retains its prior size. The example Token Manager headers now specify style/height instead of depending on section-key defaults.
  • Earlier Message acceptance includes intrinsic/grid sizing, explicit-height cycles, source failure/replacement, delayed callbacks, disabled actions, mixed-template reuse and fixed footer. Web additionally covers narrow RTL layouts and DOM-corrected Message measurement.
  • Native build logs, screenshots, accessibility trees and earlier recording/request evidence remain in the external validation runtime; dated per-template details are in docs/DESIGN.md. An earlier Android input timeout also affected Launcher; restarting only the task emulator allowed the relevant acceptance to pass. This does not establish a library-level ANR cause.
  • Stage 6 reruns all twelve families with same-key replacement, style-height set/clear, explicit header variants and token-header styles, Action/System footer replacement, scroll/reuse and WalletGroup drag/member events on all three platforms. The external consumer audit covers local app-monorepo 67fcc71204 and origin/x 62c645f906; no consumer changes were needed. Native legacy classes are deleted, footer initialization is lightweight, and Web no longer reaches into WalletGroup DOM or measures warning internals.
  • git diff --check origin/main...HEAD passes (full PR diff, including the EOF whitespace correction). Two explicit compatibility boundaries remain: Android Market pagination-tail recognition and Web DataRow asset badge placement. Existing default platform geometry and scrolling-performance measurements are separate from this architecture migration.

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
@huhuanming
huhuanming marked this pull request as ready for review September 23, 2026 23:47
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