From 2b3288f9f6edbcfd11791e9c2305f38df5538a08 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 26 Jul 2026 20:02:42 -0400 Subject: [PATCH 01/11] Expand silk-native with visual primitives and forms. Ship Surface through Progress plus Input/Textarea/Field/Checkbox/Switch/RadioGroup on the same silk-core recipes as web, with RNW a11y compat, fixtures, and gates. Slider stays deferred to avoid forking the shared array-valued contract. Co-authored-by: Cursor --- .changeset/native-visual-forms.md | 5 + apps/docs/src/Native.mdx | 60 +- .../fixtures/NativeSettingsForm.stories.tsx | 36 + .../src/fixtures/NativeSettingsForm.test.tsx | 94 +++ apps/docs/src/fixtures/NativeSettingsForm.tsx | 203 +++++ apps/docs/src/native/Forms.stories.tsx | 107 +++ apps/docs/src/native/Visual.stories.tsx | 127 ++++ apps/native-example/App.tsx | 60 ++ apps/native-example/README.md | 18 +- docs/API_POLICY.md | 9 +- docs/ARCHITECTURE.md | 2 +- docs/ROADMAP.md | 7 +- .../silk-native/src/components/Avatar.tsx | 83 +++ packages/silk-native/src/components/Badge.tsx | 44 ++ packages/silk-native/src/components/Card.tsx | 77 ++ .../silk-native/src/components/Checkbox.tsx | 184 +++++ packages/silk-native/src/components/Field.tsx | 496 +++++++++++++ .../silk-native/src/components/Heading.tsx | 51 ++ packages/silk-native/src/components/Input.tsx | 88 +++ .../silk-native/src/components/Progress.tsx | 162 ++++ .../silk-native/src/components/RadioGroup.tsx | 220 ++++++ .../silk-native/src/components/Separator.tsx | 58 ++ .../silk-native/src/components/Skeleton.tsx | 74 ++ .../silk-native/src/components/Spinner.tsx | 79 ++ .../silk-native/src/components/StatusDot.tsx | 46 ++ .../silk-native/src/components/Surface.tsx | 83 +++ .../silk-native/src/components/Switch.tsx | 152 ++++ .../silk-native/src/components/Textarea.tsx | 93 +++ .../silk-native/src/components/forms.test.tsx | 170 +++++ .../src/components/visual.test.tsx | 151 ++++ packages/silk-native/src/index.ts | 86 ++- packages/silk-native/src/styles/a11yProps.ts | 85 +++ .../silk-native/src/styles/controlGeometry.ts | 71 +- packages/silk-native/src/styles/mapStyles.ts | 694 +++++++++++++++++- .../src/styles/recipeConformance.test.ts | 194 +++++ .../src/styles/useReducedMotion.ts | 27 + .../silk-native/src/theme/SilkProvider.tsx | 33 +- perf-budgets.json | 106 ++- scripts/packed-consumer-check.mjs | 86 ++- scripts/perf-budgets.mjs | 50 +- 40 files changed, 4366 insertions(+), 105 deletions(-) create mode 100644 .changeset/native-visual-forms.md create mode 100644 apps/docs/src/fixtures/NativeSettingsForm.stories.tsx create mode 100644 apps/docs/src/fixtures/NativeSettingsForm.test.tsx create mode 100644 apps/docs/src/fixtures/NativeSettingsForm.tsx create mode 100644 apps/docs/src/native/Forms.stories.tsx create mode 100644 apps/docs/src/native/Visual.stories.tsx create mode 100644 packages/silk-native/src/components/Avatar.tsx create mode 100644 packages/silk-native/src/components/Badge.tsx create mode 100644 packages/silk-native/src/components/Card.tsx create mode 100644 packages/silk-native/src/components/Checkbox.tsx create mode 100644 packages/silk-native/src/components/Field.tsx create mode 100644 packages/silk-native/src/components/Heading.tsx create mode 100644 packages/silk-native/src/components/Input.tsx create mode 100644 packages/silk-native/src/components/Progress.tsx create mode 100644 packages/silk-native/src/components/RadioGroup.tsx create mode 100644 packages/silk-native/src/components/Separator.tsx create mode 100644 packages/silk-native/src/components/Skeleton.tsx create mode 100644 packages/silk-native/src/components/Spinner.tsx create mode 100644 packages/silk-native/src/components/StatusDot.tsx create mode 100644 packages/silk-native/src/components/Surface.tsx create mode 100644 packages/silk-native/src/components/Switch.tsx create mode 100644 packages/silk-native/src/components/Textarea.tsx create mode 100644 packages/silk-native/src/components/forms.test.tsx create mode 100644 packages/silk-native/src/components/visual.test.tsx create mode 100644 packages/silk-native/src/styles/a11yProps.ts create mode 100644 packages/silk-native/src/styles/useReducedMotion.ts diff --git a/.changeset/native-visual-forms.md b/.changeset/native-visual-forms.md new file mode 100644 index 0000000..4d5e644 --- /dev/null +++ b/.changeset/native-visual-forms.md @@ -0,0 +1,5 @@ +--- +'@reactive/silk-native': minor +--- + +Expand the native renderer with visual primitives (Surface, Card, Heading, Badge, Separator, Avatar, StatusDot, Skeleton, Spinner, Progress) and forms (Input, Textarea, Field, Checkbox, Switch, RadioGroup). Same silk-core recipes as web; RNW a11y compat helpers; Slider deferred to preserve the shared array-valued contract. diff --git a/apps/docs/src/Native.mdx b/apps/docs/src/Native.mdx index 2064c05..41268b2 100644 --- a/apps/docs/src/Native.mdx +++ b/apps/docs/src/Native.mdx @@ -8,9 +8,10 @@ React Native renderer for Silk. Same semantic `Theme` object and recipe contracts as `@reactive/silk` — delivered through React context, **not** CSS variables. Native should feel native; web should feel like the web. -Stories under **Native Components** and the **Fixtures/NativeShell** page render -these components via `react-native-web` so the docs site can exercise them -without a simulator. The real-native exit demo is `apps/native-example` (Expo). +Stories under **Native Components** and the **Fixtures/NativeShell** / +**Fixtures/NativeSettingsForm** pages render these components via +`react-native-web` so the docs site can exercise them without a simulator. The +real-native exit demo is `apps/native-example` (Expo). ## Install @@ -24,10 +25,14 @@ import { createTheme } from '@reactive/silk-core'; import { Box, Button, + Card, + Field, + Heading, Inline, + Input, SilkProvider, Stack, - Text, + Switch, } from '@reactive/silk-native'; const theme = createTheme({ colorScheme: 'light' }); @@ -36,15 +41,19 @@ export function Screen() { return ( - - Hello - - - - - + + + Hello + + Name + + + + + + + + ); @@ -83,8 +92,29 @@ No public CSS variables or `className` on native. ## Control geometry -Button padding steps and `lineHeight: 1.2` are **renderer-local**, intentionally -parallel to web `controlGeometry.ts` — not core tokens. +Button / Input / Checkbox / Switch / Spinner geometry is **renderer-local**, +expressed as density-aware space steps (parallel to web `controlGeometry.ts`) — +not core tokens. Avatar `mediaScale` sizes stay density-independent (content). + +## Native adaptations (documented) + +| Web | Native | +| --- | --- | +| Skeleton shimmer gradient | Opacity pulse (plain RN views) | +| Indeterminate Progress 35% solid-over-sunken mix | Static `tone.subtle` fill under reduced motion | +| Spinner 25% `color-mix` ring | `tone.subtle` + `tone.solid` (no color-string parsing) | +| `accessibilityLiveRegion` | Android-only enhancement; use roles + labels cross-platform | +| Slider (Radix array multi-thumb) | **Deferred** — do not ship a scalar fork | +| Roving keyboard focus (RadioGroup) | Screen readers navigate elements directly | + +RNW tests assert ARIA aliases (`aria-checked`, `aria-valuenow`, …) via the +`a11yState` / `a11yValue` helpers — RN object props alone are dropped by RNW. + +## Shipped components + +**Layout:** Box, Stack, Inline · **Visual:** Text, Button, Surface, Card, +Heading, Badge, Separator, Avatar, StatusDot, Skeleton, Spinner, Progress · +**Forms:** Input, Textarea, Field, Checkbox, Switch, RadioGroup ## Stage 1 spike findings (folded forward) diff --git a/apps/docs/src/fixtures/NativeSettingsForm.stories.tsx b/apps/docs/src/fixtures/NativeSettingsForm.stories.tsx new file mode 100644 index 0000000..62021f6 --- /dev/null +++ b/apps/docs/src/fixtures/NativeSettingsForm.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from 'storybook-react-rsbuild'; +import { withSource } from '../docsSource'; +import { + NativeSettingsForm, + nativeSettingsFormStates, +} from './NativeSettingsForm'; +import nativeSettingsFormSource from './NativeSettingsForm.tsx?raw'; + +const meta = { + title: 'Fixtures/NativeSettingsForm', + component: NativeSettingsForm, + parameters: withSource(nativeSettingsFormSource), + args: { state: 'normal' }, + argTypes: { + state: { + control: 'select', + options: [...nativeSettingsFormStates], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Normal: Story = {}; + +export const Error: Story = { args: { state: 'error' } }; +export const Disabled: Story = { args: { state: 'disabled' } }; +export const InvalidDisabled: Story = { args: { state: 'invalidDisabled' } }; +export const Compact: Story = { args: { state: 'compact' } }; +export const Dark: Story = { args: { state: 'dark' } }; +export const ReducedMotion: Story = { args: { state: 'reducedMotion' } }; +export const LongContent: Story = { args: { state: 'longContent' } }; +export const Rtl: Story = { args: { state: 'rtl' } }; +export const Indeterminate: Story = { args: { state: 'indeterminate' } }; diff --git a/apps/docs/src/fixtures/NativeSettingsForm.test.tsx b/apps/docs/src/fixtures/NativeSettingsForm.test.tsx new file mode 100644 index 0000000..3627e00 --- /dev/null +++ b/apps/docs/src/fixtures/NativeSettingsForm.test.tsx @@ -0,0 +1,94 @@ +import { expect, test } from '@rstest/core'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { + NativeSettingsForm, + nativeSettingsFormStates, + type NativeSettingsFormState, +} from './NativeSettingsForm'; + +function renderFixture(state: NativeSettingsFormState) { + return render(); +} + +test.each(nativeSettingsFormStates)( + 'NativeSettingsForm state "%s" mounts with fixture markers', + (state) => { + const { container } = renderFixture(state); + const root = container.querySelector( + '[data-fixture="native-settings-form"]', + ); + expect(root).not.toBeNull(); + expect(root?.getAttribute('data-fixture-state')).toBe(state); + expect(screen.getByText('Settings')).toBeTruthy(); + }, +); + +test('error state shows alert and summary', () => { + renderFixture('error'); + expect(screen.getByRole('alert').textContent).toContain('required'); + expect(screen.getByText(/state=error/)).toBeTruthy(); +}); + +test('disabled switches and radios expose disabled', () => { + renderFixture('disabled'); + expect( + screen.getByRole('switch').getAttribute('aria-disabled'), + ).toBe('true'); + for (const radio of screen.getAllByRole('radio')) { + expect(radio.getAttribute('aria-disabled')).toBe('true'); + } +}); + +test('invalidDisabled keeps invalid error with disabled controls', () => { + renderFixture('invalidDisabled'); + expect(screen.getByRole('alert')).toBeTruthy(); + expect( + screen.getByRole('switch').getAttribute('aria-disabled'), + ).toBe('true'); +}); + +test('compact and dark report in summary', () => { + expect(renderFixture('compact').getByText(/density=compact/)).toBeTruthy(); + expect(renderFixture('dark').getByText(/scheme=dark/)).toBeTruthy(); +}); + +test('rtl sets dir attribute', () => { + const { container } = renderFixture('rtl'); + expect( + container + .querySelector('[data-fixture="native-settings-form"]') + ?.getAttribute('dir'), + ).toBe('rtl'); +}); + +test('indeterminate checkbox starts mixed then becomes checked', () => { + renderFixture('indeterminate'); + const box = screen.getByRole('checkbox', { name: /Select all/i }); + expect(box.getAttribute('aria-checked')).toBe('mixed'); + fireEvent.click(box); + expect( + screen.getByRole('checkbox', { name: /Select all/i }).getAttribute( + 'aria-checked', + ), + ).toBe('true'); +}); + +test('progress exposes aria-valuenow', () => { + renderFixture('normal'); + const bar = screen.getByLabelText('Profile completeness'); + expect(bar.getAttribute('aria-valuenow')).toBe('72'); +}); + +test('longContent region present', () => { + const { container } = renderFixture('longContent'); + expect( + container.querySelector('[data-region="long-content"]'), + ).not.toBeNull(); +}); + +test('reducedMotion region present', () => { + const { container } = renderFixture('reducedMotion'); + expect( + container.querySelector('[data-region="reduced-motion"]'), + ).not.toBeNull(); +}); diff --git a/apps/docs/src/fixtures/NativeSettingsForm.tsx b/apps/docs/src/fixtures/NativeSettingsForm.tsx new file mode 100644 index 0000000..91b5913 --- /dev/null +++ b/apps/docs/src/fixtures/NativeSettingsForm.tsx @@ -0,0 +1,203 @@ +import { + createTheme, + generatePairedPalette, + type ColorScheme, + type DensityName, +} from '@reactive/silk-core'; +import { + Badge, + Card, + Checkbox, + Field, + Heading, + Inline, + Input, + Progress, + RadioGroup, + Separator, + SilkProvider, + Skeleton, + Spinner, + Stack, + StatusDot, + Switch, + Text, + Textarea, +} from '@reactive/silk-native'; +import { useMemo, useState, type JSX } from 'react'; + +export const nativeSettingsFormStates = [ + 'normal', + 'error', + 'disabled', + 'invalidDisabled', + 'compact', + 'dark', + 'reducedMotion', + 'longContent', + 'rtl', + 'indeterminate', +] as const; + +export type NativeSettingsFormState = + (typeof nativeSettingsFormStates)[number]; + +const paired = generatePairedPalette('#0ea5e9'); +const longBio = Array.from({ length: 80 }, () => 'content').join(' '); + +export interface NativeSettingsFormProps { + readonly state?: NativeSettingsFormState; +} + +/** + * Native forms fixture — state matrix for docs + tests (RNW). + * Slider is deferred; Progress stands in for determinate feedback. + */ +export function NativeSettingsForm({ + state = 'normal', +}: NativeSettingsFormProps): JSX.Element { + const scheme: ColorScheme = state === 'dark' ? 'dark' : 'light'; + const density: DensityName = state === 'compact' ? 'compact' : 'comfortable'; + const disabled = state === 'disabled' || state === 'invalidDisabled'; + const invalid = state === 'error' || state === 'invalidDisabled'; + const dir = state === 'rtl' ? 'rtl' : 'ltr'; + + const theme = useMemo( + () => + createTheme({ + colorScheme: scheme, + ...(state === 'dark' ? { palette: paired.dark } : {}), + }), + [scheme, state], + ); + + const [notify, setNotify] = useState(true); + const [plan, setPlan] = useState('pro'); + const [selectAll, setSelectAll] = useState( + state === 'indeterminate' ? 'indeterminate' : false, + ); + + return ( +
+ + + + + Settings + + {invalid ? 'needs attention' : 'native'} + + + + + + state={state} · scheme={scheme} · density={density} · dir={dir} + + + {state === 'reducedMotion' ? ( +
+ + + + + + reduced-motion: skeleton static / spinner dotted when OS prefers + +
+ ) : null} + + + + + Display name + + Shown on your profile. + {invalid ? ( + Display name is required. + ) : null} + + + + Bio +