diff --git a/eslint.config.mjs b/eslint.config.mjs index 6c24142..0e5864e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,7 +8,15 @@ export default [ ...nx.configs['flat/typescript'], ...nx.configs['flat/javascript'], { - ignores: ['**/dist', '**/vite.config.*.timestamp*', '**/vitest.config.*.timestamp*', 'libs/ui/scripts/**'], + // libs/react-native is a standalone Expo app with its own toolchain (metro/babel/expo) + // and tsconfig that extends expo/tsconfig.base — not resolvable by the root workspace. + ignores: [ + '**/dist', + '**/vite.config.*.timestamp*', + '**/vitest.config.*.timestamp*', + 'libs/ui/scripts/**', + 'libs/react-native/**', + ], }, { files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], @@ -39,7 +47,14 @@ export default [ 'import/resolver': { typescript: { alwaysTryTypes: true, - project: ['./tsconfig.base.json', 'libs/*/tsconfig.json', 'libs/*/tsconfig.*.json'], + project: [ + './tsconfig.base.json', + 'libs/*/tsconfig.json', + 'libs/*/tsconfig.*.json', + // Exclude the Expo app's tsconfig (extends expo/tsconfig.base, not installed at root) + // so its unresolvable `extends` does not break import resolution for every other file. + '!libs/react-native/**', + ], }, }, }, diff --git a/libs/design-core/README.md b/libs/design-core/README.md index 4a72ae3..9a8bf6e 100644 --- a/libs/design-core/README.md +++ b/libs/design-core/README.md @@ -9,7 +9,7 @@ It is the base platform intended to be consumed by: - Lit component subscribes via `store.subscribe()` / `store.getState()` inside `connectedCallback()`. No hook, no adapter package required on the Lit side beyond the subscription call itself. -- React and React Native +- **CTORNDSD-590** (`react-native/`, see its own `FINDINGS.md`) — React and React Native consume identically via `useStore(vanillaStore, selector)` (from `zustand`), since RN is still React, just a different renderer. - The existing React web implementation (`libs/ui`) — not modified by this package; adopting it there is a @@ -23,6 +23,17 @@ Covers the same 5 atoms both spikes port: `Button`, `Checkbox`, `Typography`, `I - **`tokenResolvers/`** — pure functions, one per atom, that resolve a theme object plus a variant/size into plain, platform-neutral style values (no CSS strings, no Emotion pseudo-selector objects). Every atom gets one, since token resolution is the one thing all 5 can share regardless of how much behavior they have. + **Not every adapter consumes these the same way.** CTORNDSD-581's Lit adapter imports the REAL + `gd-design-library/tokens` objects directly + `resolveThemeTree` instead, for "true single source of + truth" — it deleted (or gutted to a stub) this package's own `resolveCheckboxStyle`/`resolveInputStyle`/ + `resolveTypographyStyle`/`resolveSelectStyle` as part of that move. CTORNDSD-590's React Native adapter + cannot do the same (Metro has no equivalent alias/externals mechanism for `gd-design-library`'s DOM/ + Emotion-oriented dependency tree — see `react-native/FINDINGS.md`), so it recreated all 4 of those + resolvers here from their last-known-good pre-deletion shape. **This means an edit to + `libs/ui/src/tokens/{checkbox,input,select,typography}.ts` will propagate automatically to the Lit + adapter but NOT to the React Native adapter** — a real, accepted duplication, not an oversight. Only + `resolveButtonVariantStyle`/`resolveButtonRadius` were never deleted in the first place, since both + adapters share them unchanged. - **`stores/`** — per-atom state factories built on `zustand/vanilla`'s `createStore`, for the 3 atoms with real behavior to extract: `Checkbox` (controlled/uncontrolled resolution + indeterminate), `Input` (debounce + mouse/keyboard interaction tracking), `Select` (open/close, single/multi selection, search @@ -76,3 +87,11 @@ Resolvers accept a loosely-typed `DesignCoreTheme` (see `src/types.ts`) — a st (`colors.*`, `font.*`, `spacing.*`, `radius.*`, `values.*`). This package does not import `gd-design-library` at runtime, so it stays buildable and testable independently of `libs/ui`; any object shaped like `gd-design-library`'s `defaultTheme` (or a per-platform equivalent) satisfies it. + +## Status + +Both consuming spikes are now underway. Lit/Web-Components reports "GO (conditional)" in +`libs/web-components/FINDINGS.md`, with all 5 atoms ported. React Native reports "GO +(conditional)" in `react-native/FINDINGS.md`, also with all 5 atoms ported, using the recreated +`tokenResolvers` described above. `stores/` (`createCheckboxStore`, `createInputStore`, +`createSelectStore`) required zero changes for either adapter — they were RN/Lit-ready from the start. diff --git a/libs/design-core/src/tokenResolvers/checkbox.spec.ts b/libs/design-core/src/tokenResolvers/checkbox.spec.ts new file mode 100644 index 0000000..86ae8b5 --- /dev/null +++ b/libs/design-core/src/tokenResolvers/checkbox.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCheckboxStyle } from './checkbox'; +import type { DesignCoreTheme } from '../types'; + +const theme: DesignCoreTheme = { + colors: { bg: { fill: { primary: '#000000' } }, border: { default: '#cccccc' }, text: { default: '#171717' } }, + values: { borderMedium: '2px' }, + radius: { xs: '2px' }, + spacing: { sm: '10px' }, +}; + +describe('resolveCheckboxStyle', () => { + it('resolves size-specific dimensions for sm', () => { + const style = resolveCheckboxStyle(theme, 'sm'); + expect(style.indicatorSize).toBe(16); + expect(style.iconSize).toBe(10); + }); + + it('resolves size-specific dimensions for md (default)', () => { + const style = resolveCheckboxStyle(theme); + expect(style.indicatorSize).toBe(18); + expect(style.iconSize).toBe(12); + }); + + it('resolves checked and indeterminate to the same fill color', () => { + const style = resolveCheckboxStyle(theme, 'md'); + expect(style.indicatorChecked).toEqual({ backgroundColor: '#000000', borderColor: '#000000' }); + expect(style.indicatorIndeterminate).toEqual({ backgroundColor: '#000000', borderColor: '#000000' }); + }); + + it('resolves the default (unchecked) indicator border/radius from theme', () => { + const style = resolveCheckboxStyle(theme, 'md'); + expect(style.indicatorDefault.borderColor).toBe('#cccccc'); + expect(style.indicatorDefault.borderWidth).toBe('2px'); + expect(style.indicatorDefault.borderRadius).toBe('2px'); + expect(style.indicatorDefault.backgroundColor).toBe('transparent'); + }); + + it('falls back to real hardcoded defaults when no theme is passed', () => { + const style = resolveCheckboxStyle({}, 'md'); + expect(style.indicatorChecked.backgroundColor).toBe('#FFB800'); + expect(style.indicatorDefault.borderColor).toBe('#E5E5E5'); + expect(style.indicatorDefault.borderWidth).toBe('2px'); + expect(style.indicatorDefault.borderRadius).toBe('2px'); + }); + + it('does not resolve any label typography — Checkbox.tsx renders its label as a bare, unstyled span', () => { + const style = resolveCheckboxStyle(theme, 'md'); + expect(style).not.toHaveProperty('labelColor'); + expect(style).not.toHaveProperty('labelFontFamily'); + expect(style).not.toHaveProperty('labelFontSize'); + expect(style).not.toHaveProperty('labelLineHeight'); + }); + + it("resolves the wrapper gap from the theme's shared spacing token, matching wrapper.default", () => { + expect(resolveCheckboxStyle(theme, 'md').wrapperGap).toBe('10px'); + }); + + it('falls back to the real spacing scale for wrapper gap when no theme is passed', () => { + expect(resolveCheckboxStyle({}, 'md').wrapperGap).toBe('8px'); + }); +}); diff --git a/libs/design-core/src/tokenResolvers/checkbox.ts b/libs/design-core/src/tokenResolvers/checkbox.ts index 9f0978b..68b90e3 100644 --- a/libs/design-core/src/tokenResolvers/checkbox.ts +++ b/libs/design-core/src/tokenResolvers/checkbox.ts @@ -1,14 +1,74 @@ +import { get } from '../utils/get'; +import type { DesignCoreTheme } from '../types'; + /** Mirrors gd-design-library's `CheckboxSize` (libs/ui/src/components/atoms/Checkbox/Checkbox.types.ts). */ export type CheckboxSizeName = 'sm' | 'md'; +export interface ResolvedCheckboxStyle { + indicatorSize: number; + iconSize: number; + indicatorDefault: { + borderWidth: string | number; + borderColor: string; + backgroundColor: string; + borderRadius: string | number; + }; + indicatorChecked: { backgroundColor: string; borderColor: string }; + indicatorIndeterminate: { backgroundColor: string; borderColor: string }; + /** `checkbox.wrapper.default.gap` — `get(theme, 'spacing.sm', ...)`. Unlike `label`, `wrapper` + * genuinely is consumed by `CheckboxWrapperStyled`. */ + wrapperGap: string | number; +} + +/** Matches libs/ui/src/tokens/checkbox.ts `size` scale (px box size + icon size). */ +const SIZE_PX: Record = { + sm: { box: 16, icon: 10 }, + md: { box: 18, icon: 12 }, +}; + /** - * There is deliberately no `resolveCheckboxStyle` hand-mirroring `checkbox.ts`'s object here — - * that would be a second, manually-kept-in-sync copy of the same data (including its `size` - * scale and hex fallbacks). `gd-checkbox.ts` instead imports the REAL `checkbox` object from - * `gd-design-library/tokens` and resolves it directly with `resolveThemeTree`, so any edit to - * `libs/ui/src/tokens/checkbox.ts` is picked up automatically. No `label` fields are read from - * it either way — `Checkbox.tsx` renders its label as a bare, unstyled span, so the token - * file's `label` block is never actually consumed by the real component; its font/color is - * 100% ambient CSS inheritance, which a Shadow-DOM span with no explicit style already gets for - * free. + * CTORNDSD-590: recreated from this file's own pre-CTORNDSD-581-deletion shape (commit + * `74c1ea6`, deleted in `7e47cec`) because `react-native` has no DOM/CSS runtime to + * resolve `gd-design-library/tokens` + `resolveThemeTree` the way `gd-checkbox.ts` (Lit) does — + * see this package's README "Status"/"Theme parameter" sections for the accepted duplication + * trade-off. Any edit to `libs/ui/src/tokens/checkbox.ts` will NOT automatically propagate here; + * re-sync by hand if the real token file changes. Do not delete this again under the + * "single source of truth" reasoning that removed it the first time without checking whether + * `react-native` still consumes it. + * + * No `label*` fields here on purpose — a previous revision added `labelColor`/`labelFontFamily`/ + * `labelFontSize`/`labelLineHeight` sourced from `libs/ui/src/tokens/checkbox.ts`'s `label` + * block, but that block is never actually consumed by the real component: `Checkbox.tsx` + * renders its label as a bare `{children && {children}}` with no + * `css`/`style` prop at all — the token file defines a `label` shape nothing reads. The real + * label's font/color is 100% ambient CSS inheritance (from whatever the host page/global reset + * provides), which is also what a Shadow-DOM span with no explicit style resolves to, since + * inherited CSS properties (color, font-family, font-size, line-height) cross the shadow + * boundary same as any other DOM inheritance. Do not re-add explicit label typography here + * without first confirming the real component's JSX actually applies the token you're mirroring + * — check the component's `.tsx`, not just its token file. */ +export function resolveCheckboxStyle(theme: DesignCoreTheme, size: CheckboxSizeName = 'md'): ResolvedCheckboxStyle { + const { box, icon } = SIZE_PX[size] ?? SIZE_PX.md; + const fillPrimary = get(theme, 'colors.bg.fill.primary', '#FFB800'); + + return { + indicatorSize: box, + iconSize: icon, + indicatorDefault: { + borderWidth: get(theme, 'values.borderMedium', '2px'), + borderColor: get(theme, 'colors.border.default', '#E5E5E5'), + backgroundColor: 'transparent', + borderRadius: get(theme, 'radius.xs', '2px'), + }, + indicatorChecked: { + backgroundColor: fillPrimary, + borderColor: fillPrimary, + }, + indicatorIndeterminate: { + backgroundColor: fillPrimary, + borderColor: fillPrimary, + }, + wrapperGap: get(theme, 'spacing.sm', '8px'), + }; +} diff --git a/libs/design-core/src/tokenResolvers/index.ts b/libs/design-core/src/tokenResolvers/index.ts index a18a4a9..441bcc1 100644 --- a/libs/design-core/src/tokenResolvers/index.ts +++ b/libs/design-core/src/tokenResolvers/index.ts @@ -8,8 +8,20 @@ export type { ButtonTokenTree, } from './button'; -export type { TypographyVariantName, TypographyStyleVariantName } from './typography'; +export { resolveTypographyStyle } from './typography'; +export type { TypographyVariantName, TypographyStyleVariantName, ResolvedTypographyStyle } from './typography'; -export type { CheckboxSizeName } from './checkbox'; +export { resolveCheckboxStyle } from './checkbox'; +export type { CheckboxSizeName, ResolvedCheckboxStyle } from './checkbox'; -export type { InputColorVariantName } from './input'; +export { + resolveInputStyle, + COLOR_VARIANT_BORDER_PATH, + COLOR_VARIANT_BORDER_DEFAULT, + HELPER_TEXT_COLOR_PATH, + HELPER_TEXT_COLOR_DEFAULT, +} from './input'; +export type { InputColorVariantName, ResolvedInputStyle } from './input'; + +export { resolveSelectStyle } from './select'; +export type { ResolvedSelectStyle } from './select'; diff --git a/libs/design-core/src/tokenResolvers/input.spec.ts b/libs/design-core/src/tokenResolvers/input.spec.ts new file mode 100644 index 0000000..5829f3c --- /dev/null +++ b/libs/design-core/src/tokenResolvers/input.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { resolveInputStyle } from './input'; +import type { DesignCoreTheme } from '../types'; + +const theme: DesignCoreTheme = { + font: { + family: 'Fira Sans', + size: { p: '16px', small: '15px', caption: '13px' }, + line: { height: { small: '21px', caption: '17px' } }, + }, + colors: { + text: { default: '#171717', disabled: '#a3a3a3', success: '#0b6', primary: '#111111', error: '#e44' }, + border: { default: '#cccccc', success: '#0a5', primary: '#000000', error: '#d33', focus: '#0a5a9c' }, + }, + values: { borderThin: '1px' }, + spacing: { xs: '5px', sm: '9px' }, + zIndex: { first: 2 }, + radius: { none: '1px' }, +}; + +describe('resolveInputStyle', () => { + it('resolves the primary color variant by default', () => { + const style = resolveInputStyle(theme); + expect(style.borderColor).toBe('#cccccc'); + }); + + it.each([ + ['success', '#0a5'], + ['warning', '#000000'], + ['error', '#d33'], + ] as const)('resolves the %s color variant border', (color, expected) => { + expect(resolveInputStyle(theme, color).borderColor).toBe(expected); + }); + + it('resolves shared typography/border values regardless of color', () => { + const style = resolveInputStyle(theme, 'error'); + expect(style.fontFamily).toBe('Fira Sans'); + expect(style.fontSize).toBe('16px'); + expect(style.borderWidth).toBe('1px'); + expect(style.color).toBe('#171717'); + expect(style.disabledColor).toBe('#a3a3a3'); + }); + + it.each([ + ['primary', '#E5E5E5'], + ['success', '#34A853'], + ['warning', '#FFB800'], + ['error', '#D21C1C'], + ] as const)( + 'falls back to the real %s border color when no theme is passed, not one flat gray', + (color, expected) => { + expect(resolveInputStyle({}, color).borderColor).toBe(expected); + } + ); + + it("resolves the label color from the theme's colors.text.default, regardless of color variant", () => { + expect(resolveInputStyle(theme, 'error').labelColor).toBe('#171717'); + expect(resolveInputStyle(theme, 'success').labelColor).toBe('#171717'); + }); + + it.each([ + ['primary', '#171717'], + ['success', '#0b6'], + ['warning', '#111111'], + ['error', '#e44'], + ] as const)('resolves the %s helper-text color from theme', (color, expected) => { + expect(resolveInputStyle(theme, color).helperTextColor).toBe(expected); + }); + + it.each([ + ['primary', '#000000'], + ['success', '#1F843A'], + ['warning', '#FFB800'], + ['error', '#BD1919'], + ] as const)('falls back to the real %s helper-text color when no theme is passed', (color, expected) => { + expect(resolveInputStyle({}, color).helperTextColor).toBe(expected); + }); + + it('falls back to the real label color when no theme is passed', () => { + expect(resolveInputStyle({}).labelColor).toBe('#000000'); + }); + + it("resolves gap/typography/zIndex/padding/radius from the theme's shared tokens", () => { + const style = resolveInputStyle(theme); + expect(style.wrapperGap).toBe('5px'); + expect(style.labelFontSize).toBe('15px'); + expect(style.labelLineHeight).toBe('21px'); + expect(style.helperFontSize).toBe('13px'); + expect(style.helperLineHeight).toBe('17px'); + expect(style.zIndex).toBe(2); + expect(style.horizontalPadding).toBe('9px'); + expect(style.borderRadius).toBe('1px'); + }); + + it('falls back to the real values for gap/typography/zIndex/padding/radius when no theme is passed', () => { + const style = resolveInputStyle({}); + expect(style.wrapperGap).toBe('4px'); + expect(style.labelFontSize).toBe('14px'); + expect(style.labelLineHeight).toBe('20px'); + expect(style.helperFontSize).toBe('12px'); + expect(style.helperLineHeight).toBe('16px'); + expect(style.zIndex).toBe(1); + expect(style.horizontalPadding).toBe('8px'); + expect(style.borderRadius).toBe('0px'); + }); + + it("resolves focusColor from the theme's colors.border.focus", () => { + expect(resolveInputStyle(theme).focusColor).toBe('#0a5a9c'); + }); + + it('falls back to the real focus color when no theme is passed', () => { + expect(resolveInputStyle({}).focusColor).toBe('#0069B4'); + }); +}); diff --git a/libs/design-core/src/tokenResolvers/input.ts b/libs/design-core/src/tokenResolvers/input.ts index 00314cf..b66b787 100644 --- a/libs/design-core/src/tokenResolvers/input.ts +++ b/libs/design-core/src/tokenResolvers/input.ts @@ -1,3 +1,6 @@ +import { get } from '../utils/get'; +import type { DesignCoreTheme } from '../types'; + /** Matches gd-design-library's real `InputColorVariant` (libs/ui/src/types/input.ts) exactly — * member names and values both, not just the resolved colors. `warning` is the real * component's semantic role name for the `colors.border.primary` (brand-gold) token, and @@ -6,15 +9,119 @@ * (Select reuses the same `primary`/`success`/`warning`/`error` vocabulary as Input). */ export type InputColorVariantName = 'primary' | 'success' | 'warning' | 'error'; +export interface ResolvedInputStyle { + fontFamily: string | number; + fontSize: string | number; + color: string; + disabledColor: string; + borderWidth: string | number; + borderColor: string; + /** `libs/ui/src/components/atoms/Input/Input.tsx` renders its `label` prop through + * `` with no explicit `color`/`size`, i.e. `InputHelper`'s own defaults + * (`color: 'primary'`, `size: 'md'`) — which always resolves to `colors.text.default` + * regardless of Input's own `color` variant. */ + labelColor: string; + /** `Input.tsx` renders `helperText` through `` — + * unlike the label, this IS color-variant-dependent (`helper..sm.color`). */ + helperTextColor: string; + /** `input.wrapper.withGap.gap` — `get(theme, 'spacing.xs', ...)`. Applied only when label or + * helperText is present (real `hasHelpers` condition), same as `InputWrapper`'s `$withGap`. */ + wrapperGap: string | number; + /** `input.helper.default.md.{fontSize,lineHeight}` — the real `label`'s font metrics + * (`InputHelper`'s own default `size: 'md'`). */ + labelFontSize: string | number; + labelLineHeight: string | number; + /** `input.helper.default.sm.{fontSize,lineHeight}` — the real `helperText`'s font metrics + * (`Input.tsx` passes `size="sm"` explicitly). */ + helperFontSize: string | number; + helperLineHeight: string | number; + /** `input.input.default['&:not(...)'].zIndex` — `get(theme, 'zIndex.first', ...)`. */ + zIndex: string | number; + /** `input.input.default.padding` — `get(theme, 'spacing.sm', ...)`. */ + horizontalPadding: string | number; + /** `input.input.defaultInteraction['& + .Input__border'].borderRadius` — `radius.none`. */ + borderRadius: string | number; + /** `input.ts`'s `'&:focus-visible' ~ .Input__outline` renders a `borders.focus(theme)` — + * `2px solid ` — as a separate offset outline layer. RN has no outline + * concept, so adapters approximate it as a border-color/width swap on focus instead of a + * second layer; this is that color, shared with `button.ts`'s own `focusColor` field. */ + focusColor: string; +} + +export const COLOR_VARIANT_BORDER_PATH: Record = { + primary: 'colors.border.default', + success: 'colors.border.success', + warning: 'colors.border.primary', + error: 'colors.border.error', +}; + +/** Real libs/ui/src/tokens/colors.ts border values per variant — used as the `get()` fallback + * so a themeless render still shows the correct color-variant border, not one flat gray. */ +export const COLOR_VARIANT_BORDER_DEFAULT: Record = { + primary: '#E5E5E5', + success: '#34A853', + warning: '#FFB800', + error: '#D21C1C', +}; + +/** Real libs/ui/src/tokens/input.ts `helper..sm.color` path per variant. */ +export const HELPER_TEXT_COLOR_PATH: Record = { + primary: 'colors.text.default', + success: 'colors.text.success', + warning: 'colors.text.primary', + error: 'colors.text.error', +}; + +/** Real libs/ui/src/tokens/colors.ts text values per variant — used as the `get()` fallback. */ +export const HELPER_TEXT_COLOR_DEFAULT: Record = { + primary: '#000000', + success: '#1F843A', + warning: '#FFB800', + error: '#BD1919', +}; + /** - * There is deliberately no `resolveInputStyle` hand-mirroring `input.ts`'s object here — that - * would be a second, manually-kept-in-sync copy of the same deeply-nested data (color-variant - * border paths, helper-text color paths, per-size font metrics). `gd-input.ts` instead imports - * the REAL `input` object from `gd-design-library/tokens` and resolves it directly with - * `resolveThemeTree`, reading the exact same nested paths this file used to hand-duplicate - * (`input.wrapper.withGap.gap`, `input.helper.default.{sm,md}`, `input.helper..sm.color`, - * `input.input.default.padding`, `input.input.defaultInteraction['& + .Input__border'].borderRadius`, - * `input.input.['& + .Input__border']`), so any edit to `libs/ui/src/tokens/input.ts` - * is picked up automatically. The debounce/interaction-tracking behavior that gives Input its - * real portability value lives in `stores/createInputStore.ts`, unaffected by this change. + * CTORNDSD-590: recreated from this file's own pre-CTORNDSD-581-deletion shape (commit + * `74c1ea6`, deleted in `7e47cec`) because `react-native` has no DOM/CSS runtime to + * resolve `gd-design-library/tokens` + `resolveThemeTree` the way `gd-input.ts` (Lit) does — + * see this package's README "Status"/"Theme parameter" sections for the accepted duplication + * trade-off. Any edit to `libs/ui/src/tokens/input.ts` will NOT automatically propagate here; + * re-sync by hand if the real token file changes. Do not delete this again under the + * "single source of truth" reasoning that removed it the first time without checking whether + * `react-native`/`select.ts` still consume it. The debounce/interaction-tracking behavior + * that gives Input its real portability value lives in `stores/createInputStore.ts`, unaffected + * by this recreation — this resolver only covers the static style values every adapter needs + * regardless of interaction state. */ +export function resolveInputStyle( + theme: DesignCoreTheme, + color: InputColorVariantName = 'primary' +): ResolvedInputStyle { + return { + fontFamily: get(theme, 'font.family', '"Fira Sans", sans-serif'), + fontSize: get(theme, 'font.size.p', '16px'), + color: get(theme, 'colors.text.default', '#000000'), + disabledColor: get(theme, 'colors.text.disabled', '#A3A3A3'), + borderWidth: get(theme, 'values.borderThin', '1px'), + borderColor: get( + theme, + COLOR_VARIANT_BORDER_PATH[color] ?? COLOR_VARIANT_BORDER_PATH.primary, + COLOR_VARIANT_BORDER_DEFAULT[color] ?? COLOR_VARIANT_BORDER_DEFAULT.primary + ), + labelColor: get(theme, 'colors.text.default', '#000000'), + helperTextColor: get( + theme, + HELPER_TEXT_COLOR_PATH[color] ?? HELPER_TEXT_COLOR_PATH.primary, + HELPER_TEXT_COLOR_DEFAULT[color] ?? HELPER_TEXT_COLOR_DEFAULT.primary + ), + wrapperGap: get(theme, 'spacing.xs', '4px'), + labelFontSize: get(theme, 'font.size.small', '14px'), + labelLineHeight: get(theme, 'font.line.height.small', '20px'), + helperFontSize: get(theme, 'font.size.caption', '12px'), + helperLineHeight: get(theme, 'font.line.height.caption', '16px'), + zIndex: get(theme, 'zIndex.first', 1), + horizontalPadding: get(theme, 'spacing.sm', '8px'), + borderRadius: get(theme, 'radius.none', '0px'), + focusColor: get(theme, 'colors.border.focus', '#0069B4'), + }; +} diff --git a/libs/design-core/src/tokenResolvers/select.spec.ts b/libs/design-core/src/tokenResolvers/select.spec.ts new file mode 100644 index 0000000..158894f --- /dev/null +++ b/libs/design-core/src/tokenResolvers/select.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSelectStyle } from './select'; +import type { DesignCoreTheme } from '../types'; + +const theme: DesignCoreTheme = { + font: { family: 'Fira Sans', size: { p: '16px' }, weight: { normal: 400 } }, + colors: { + text: { default: '#171717' }, + bg: { surface: '#ffffff', fill: { hover: '#f5f5f5' } }, + border: { default: '#cccccc', primary: '#000000', focus: '#0a5a9c' }, + }, + values: { borderThin: '1px' }, + spacing: { sm: '10px', none: 0 }, +}; + +describe('resolveSelectStyle', () => { + it('resolves the primary color-variant border and surface values by default', () => { + const style = resolveSelectStyle(theme); + expect(style.borderColor).toBe('#cccccc'); + expect(style.surfaceColor).toBe('#ffffff'); + expect(style.hoverBackgroundColor).toBe('#f5f5f5'); + }); + + it('resolves a non-default color variant border', () => { + expect(resolveSelectStyle(theme, 'warning').borderColor).toBe('#000000'); + }); + + it('resolves shared typography values', () => { + const style = resolveSelectStyle(theme); + expect(style.fontFamily).toBe('Fira Sans'); + expect(style.fontSize).toBe('16px'); + expect(style.fontWeight).toBe(400); + }); + + it('falls back to real hardcoded defaults when no theme is passed', () => { + const style = resolveSelectStyle({}, 'success'); + expect(style.borderColor).toBe('#34A853'); + expect(style.surfaceColor).toBe('#FFFFFF'); + expect(style.hoverBackgroundColor).toBe('#FFF7E5'); + expect(style.boxShadow).toBe('0px 8px 15px 1px rgba(0, 0, 0, 0.20)'); + }); + + it("honors the theme's own spacing scale for trigger/dropdown padding, not a hardcoded literal", () => { + const style = resolveSelectStyle(theme); + expect(style.triggerPadding).toBe('10px'); + expect(style.dropdownPadding).toBe(0); + }); + + it('falls back to the real spacing scale for padding when no theme is passed', () => { + const style = resolveSelectStyle({}); + expect(style.triggerPadding).toBe('8px'); + expect(style.dropdownPadding).toBe(0); + }); + + it("resolves focusColor from the theme's colors.border.focus", () => { + expect(resolveSelectStyle(theme).focusColor).toBe('#0a5a9c'); + }); + + it('falls back to the real focus color when no theme is passed', () => { + expect(resolveSelectStyle({}).focusColor).toBe('#0069B4'); + }); +}); diff --git a/libs/design-core/src/tokenResolvers/select.ts b/libs/design-core/src/tokenResolvers/select.ts new file mode 100644 index 0000000..e521edf --- /dev/null +++ b/libs/design-core/src/tokenResolvers/select.ts @@ -0,0 +1,65 @@ +import { get } from '../utils/get'; +import type { DesignCoreTheme } from '../types'; +import { COLOR_VARIANT_BORDER_PATH, COLOR_VARIANT_BORDER_DEFAULT, type InputColorVariantName } from './input'; + +export interface ResolvedSelectStyle { + fontFamily: string | number; + fontSize: string | number; + fontWeight: string | number; + color: string; + surfaceColor: string; + borderWidth: string | number; + borderColor: string; + hoverBackgroundColor: string; + boxShadow: string; + /** `select.ts`'s `button.default.padding` — `get(theme, 'spacing.sm', ...)`. */ + triggerPadding: string | number; + /** `select.ts`'s `dropdown.padding`/`margin` — both `get(theme, 'spacing.none', ...)`. */ + dropdownPadding: string | number; + /** The real trigger renders as a `Button variant="inherit"` under the hood, so its + * `:focus-visible` ring is `button.ts`'s own `getFocusStyles({ inset: '-4px', border: '2px + * solid colors.border.focus' })` — not anything defined in `select.ts` itself. Shared with + * `button.ts`'s/`input.ts`'s own `focusColor` field for the same token/fallback. */ + focusColor: string; +} + +/** + * CTORNDSD-590: new file — unlike `checkbox.ts`/`input.ts`/`typography.ts` (gutted to a + * doc-comment-only stub when CTORNDSD-581 switched to real-token resolution), this resolver was + * deleted outright since Select had no other RN-blocking reason to keep a stub around. Recreated + * from commit `74c1ea6`'s pre-deletion content (deleted in `7e47cec`) because `react-native` + * has no DOM/CSS runtime to resolve `gd-design-library/tokens` + `resolveThemeTree` the way + * `gd-select.ts` (Lit) does. Any edit to `libs/ui/src/tokens/select.ts` will NOT automatically + * propagate here; re-sync by hand if the real token file changes. + * + * Select reuses the same `primary`/`success`/`warning`/`error` color-variant scale as Input + * (imported from `./input`, not re-declared). The open/close/selection/search behavior that + * makes Select the highest shared-core-feasibility atom lives in `stores/createSelectStore.ts` + * — dropdown viewport positioning, portal/overlay rendering, and keyboard-arrow focus traversal + * stay in each platform's own adapter, since those are genuinely rendering-specific (see + * `react-native/FINDINGS.md`'s Select-approach-evaluation section for the RN adapter's + * dropdown-presentation decision). + */ +export function resolveSelectStyle( + theme: DesignCoreTheme, + color: InputColorVariantName = 'primary' +): ResolvedSelectStyle { + return { + fontFamily: get(theme, 'font.family', '"Fira Sans", sans-serif'), + fontSize: get(theme, 'font.size.p', '16px'), + fontWeight: get(theme, 'font.weight.normal', 400), + color: get(theme, 'colors.text.default', '#000000'), + surfaceColor: get(theme, 'colors.bg.surface', '#FFFFFF'), + borderWidth: get(theme, 'values.borderThin', '1px'), + borderColor: get( + theme, + COLOR_VARIANT_BORDER_PATH[color] ?? COLOR_VARIANT_BORDER_PATH.primary, + COLOR_VARIANT_BORDER_DEFAULT[color] ?? COLOR_VARIANT_BORDER_DEFAULT.primary + ), + hoverBackgroundColor: get(theme, 'colors.bg.fill.hover', '#FFF7E5'), + boxShadow: get(theme, 'shadows.box["3"]', '0px 8px 15px 1px rgba(0, 0, 0, 0.20)'), + triggerPadding: get(theme, 'spacing.sm', '8px'), + dropdownPadding: get(theme, 'spacing.none', 0), + focusColor: get(theme, 'colors.border.focus', '#0069B4'), + }; +} diff --git a/libs/design-core/src/tokenResolvers/typography.spec.ts b/libs/design-core/src/tokenResolvers/typography.spec.ts new file mode 100644 index 0000000..c58964c --- /dev/null +++ b/libs/design-core/src/tokenResolvers/typography.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTypographyStyle } from './typography'; +import type { DesignCoreTheme } from '../types'; + +const theme: DesignCoreTheme = { + font: { + family: 'Fira Sans', + weight: { light: 300, normal: 400, medium: 500, bold: 700 }, + size: { h1: '48px', p: '16px', small: '12px' }, + line: { height: { h1: '56px', p: '24px', small: '16px' } }, + }, +}; + +describe('resolveTypographyStyle', () => { + it('resolves span as fully-inherited values', () => { + const style = resolveTypographyStyle(theme, 'span'); + expect(style).toEqual({ + fontFamily: 'Fira Sans', + fontSize: 'inherit', + fontWeight: 'inherit', + lineHeight: 'inherit', + }); + }); + + it('resolves h1 from the font scale', () => { + const style = resolveTypographyStyle(theme, 'h1'); + expect(style.fontSize).toBe('48px'); + expect(style.lineHeight).toBe('56px'); + expect(style.fontWeight).toBe(400); + }); + + it('overlays a single styleVariant', () => { + const style = resolveTypographyStyle(theme, 'p', 'bold'); + expect(style.fontWeight).toBe(700); + }); + + it('overlays multiple styleVariants in order', () => { + const style = resolveTypographyStyle(theme, 'p', ['bold', 'uppercase', 'underline']); + expect(style.fontWeight).toBe(700); + expect(style.textTransform).toBe('uppercase'); + expect(style.textDecoration).toBe('underline'); + }); + + it('the "small" styleVariant overrides fontSize independent of variant', () => { + const style = resolveTypographyStyle(theme, 'h1', 'small'); + expect(style.fontSize).toBe('12px'); + }); + + it('defaults to span when no variant is passed', () => { + expect(resolveTypographyStyle(theme)).toEqual(resolveTypographyStyle(theme, 'span')); + }); + + it('resolves h1-h6 heading margins from the theme, and omits them for non-heading variants', () => { + expect(resolveTypographyStyle(theme, 'h1')).toMatchObject({ marginTop: '32px', marginBottom: '32px' }); + expect(resolveTypographyStyle(theme, 'p').marginTop).toBeUndefined(); + expect(resolveTypographyStyle(theme, 'span').marginTop).toBeUndefined(); + }); + + it('resolves the monospace family for code/kbd via the flat "family.code" key, not a nested path', () => { + const codeTheme: DesignCoreTheme = { font: { ...theme.font, 'family.code': '"Fira Code", Monaco' } }; + expect(resolveTypographyStyle(codeTheme, 'code').fontFamily).toBe('"Fira Code", Monaco'); + expect(resolveTypographyStyle(codeTheme, 'kbd').fontFamily).toBe('"Fira Code", Monaco'); + }); + + it('falls back to real hardcoded metrics/weights when no theme is passed at all', () => { + const style = resolveTypographyStyle({}, 'h1'); + expect(style.fontSize).toBe('48px'); + expect(style.lineHeight).toBe('56px'); + expect(style.marginTop).toBe('32px'); + + expect(resolveTypographyStyle({}, 'p', 'semibold').fontWeight).toBe(500); + expect(resolveTypographyStyle({}, 'p', 'light').fontWeight).toBe(300); + expect(resolveTypographyStyle({}, 'p', 'bold').fontWeight).toBe(700); + }); +}); diff --git a/libs/design-core/src/tokenResolvers/typography.ts b/libs/design-core/src/tokenResolvers/typography.ts index c11ee20..cbd7014 100644 --- a/libs/design-core/src/tokenResolvers/typography.ts +++ b/libs/design-core/src/tokenResolvers/typography.ts @@ -1,3 +1,6 @@ +import { get } from '../utils/get'; +import type { DesignCoreTheme } from '../types'; + /** Mirrors gd-design-library's `TypographyVariant` values that resolve to a real scale entry. */ export type TypographyVariantName = | 'span' @@ -26,13 +29,147 @@ export type TypographyStyleVariantName = | 'underline' | 'strike'; +export interface ResolvedTypographyStyle { + fontFamily: string | number; + fontSize?: string | number; + fontWeight?: string | number; + lineHeight?: string | number; + fontStyle?: string; + textTransform?: string; + textDecoration?: string; + /** Only set for h1-h6 (libs/ui/src/tokens/typography.ts sets explicit margins for headings + * only) — every other variant (p, span, small, etc.) intentionally has no margin override, + * so the browser's own UA default stays, matching the real component's behavior. */ + marginTop?: string; + marginBottom?: string; +} + +const MONOSPACE_VARIANTS: ReadonlySet = new Set(['code', 'kbd']); + +/** Real libs/ui/src/tokens/font.ts size/line-height per variant — used as `get()` fallbacks + * so a themeless render still shows correct metrics instead of `undefined` (no styling). */ +const VARIANT_FONT_SIZE: Partial> = { + h1: '48px', + h2: '34px', + h3: '28px', + h4: '24px', + h5: '20px', + h6: '18px', + p: '16px', + small: '14px', + caption: '12px', + header: '8px', + code: '16px', + kbd: '14px', +}; + +const VARIANT_LINE_HEIGHT: Partial> = { + h1: '56px', + h2: '36px', + h3: '32px', + h4: '28px', + h5: '26px', + h6: '24px', + p: '24px', + small: '20px', + caption: '16px', + header: '16px', + code: '24px', + kbd: '20px', +}; + +/** Real libs/ui/src/tokens/typography.ts heading margins (top === bottom); only h1-h6 set + * an explicit margin — every other variant is intentionally absent from this map. */ +const VARIANT_MARGIN: Partial> = { + h1: '32px', + h2: '24px', + h3: '16px', + h4: '16px', + h5: '8px', + h6: '8px', +}; + /** - * There is deliberately no `resolveTypographyStyle` hand-mirroring `typography.ts`'s object - * here — that would be a second, manually-kept-in-sync copy of the same data. `gd-typography.ts` - * instead imports the REAL `typography` object from `gd-design-library/tokens` and resolves it - * directly with `resolveThemeTree` (from this package's `utils/resolveThemeTree`), so any edit - * to `libs/ui/src/tokens/typography.ts` is picked up automatically. See `gd-typography.ts` for - * the variant/styleVariant merge logic (mirroring `Typography.tsx`'s own prop behavior, minus - * the DOM-tag polymorphism, has no portable equivalent across - * Lit or React Native). + * Recreated from this file's own pre-deletion shape because `react-native` has no DOM/CSS runtime to + * resolve `gd-design-library/tokens` + `resolveThemeTree` the way `gd-typography.ts` (Lit) does + * — see this package's README "Status"/"Theme parameter" sections for the accepted duplication + * trade-off. Any edit to `libs/ui/src/tokens/typography.ts` will NOT automatically propagate + * here; re-sync by hand if the real token file changes. Do not delete this again under the + * "single source of truth" reasoning that removed it the first time without checking whether + * `react-native` still consumes it. + * + * A variant resolves the base font metrics, an optional styleVariant (or list of them) overlays + * weight/transform/decoration on top — mirroring `Typography.tsx`'s `variant`/`styleVariant` + * props, minus the DOM-tag polymorphism (`as`), which per CTORNDSD-580's Typography finding has + * no portable equivalent across Lit (fixed outer custom-element tag) or React Native (`Text` + * only — RN has no tag concept at all, a stronger version of the same gap). */ +export function resolveTypographyStyle( + theme: DesignCoreTheme, + variant: TypographyVariantName = 'span', + styleVariant?: TypographyStyleVariantName | TypographyStyleVariantName[] +): ResolvedTypographyStyle { + const fontFamily = get(theme, 'font.family', '"Fira Sans", sans-serif'); + const style: ResolvedTypographyStyle = { fontFamily }; + + if (variant === 'span') { + style.fontSize = 'inherit'; + style.fontWeight = 'inherit'; + style.lineHeight = 'inherit'; + } else { + style.fontSize = get(theme, `font.size.${variant}`, VARIANT_FONT_SIZE[variant]); + style.lineHeight = get(theme, `font.line.height.${variant}`, VARIANT_LINE_HEIGHT[variant]); + style.fontWeight = get(theme, 'font.weight.normal', 400); + if (VARIANT_MARGIN[variant]) { + style.marginTop = VARIANT_MARGIN[variant]; + style.marginBottom = VARIANT_MARGIN[variant]; + } + } + + if (MONOSPACE_VARIANTS.has(variant)) { + // Real token key is the flat property `'family.code'` under `font` (a literal dot in the + // key, not a nested `font.family.code` path — `font.family` is itself a string, so a + // dot-split path would silently always miss and fall back). Array-form path segments are + // used as-is by `get()`, so `['font', 'family.code']` reaches the real flat key correctly. + style.fontFamily = get(theme, ['font', 'family.code'], '"Fira Code", Monaco'); + } + + const styleVariants = Array.isArray(styleVariant) ? styleVariant : styleVariant ? [styleVariant] : []; + + for (const sv of styleVariants) { + switch (sv) { + case 'light': + style.fontWeight = get(theme, 'font.weight.light', 300); + break; + case 'normal': + style.fontWeight = get(theme, 'font.weight.normal', 400); + break; + case 'semibold': + style.fontWeight = get(theme, 'font.weight.medium', 500); + break; + case 'bold': + style.fontWeight = get(theme, 'font.weight.bold', 700); + break; + case 'italic': + style.fontStyle = 'italic'; + break; + case 'small': + style.fontSize = get(theme, 'font.size.small', '14px'); + break; + case 'uppercase': + style.textTransform = 'uppercase'; + break; + case 'lowercase': + style.textTransform = 'lowercase'; + break; + case 'underline': + style.textDecoration = 'underline'; + break; + case 'strike': + style.textDecoration = 'line-through'; + break; + } + } + + return style; +} diff --git a/libs/react-native/.gitignore b/libs/react-native/.gitignore new file mode 100644 index 0000000..320f588 --- /dev/null +++ b/libs/react-native/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.expo/ +dist/ +web-build/ +*.log diff --git a/libs/react-native/App.tsx b/libs/react-native/App.tsx new file mode 100644 index 0000000..d8d6cc8 --- /dev/null +++ b/libs/react-native/App.tsx @@ -0,0 +1,117 @@ +// The real gd-design-library/styles.css — imported from dist/libs/ui (build output), same +// test-harness-only exception `libs/web-components/harness/fidelity-check.tsx` already makes +// (see that file's own comment for the full rationale). Loads the real Fira Sans/Fira Code +// Google Fonts + base reset so this visual-fidelity harness's web build renders the real +// typeface instead of falling back to the browser's generic sans-serif — not a source +// dependency on gd-design-library (this package still only depends on gd-design-core). Expo's +// Metro web support treats CSS imports as a no-op on iOS/Android, so this is safe cross-platform. +// Run `npm run build:ui` first if this import 404s. +// eslint-disable-next-line @nx/enforce-module-boundaries +import '../../dist/libs/ui/styles.css'; +import { useState } from 'react'; +import { StatusBar } from 'expo-status-bar'; +import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native'; +import { useGdFonts } from './src/fonts'; +import { GdButton } from './src/components/GdButton/GdButton'; +import { GdCheckbox } from './src/components/GdCheckbox/GdCheckbox'; +import { GdTypography } from './src/components/GdTypography/GdTypography'; +import { GdInput } from './src/components/GdInput/GdInput'; +import { GdSelect } from './src/components/GdSelect/GdSelect'; + +/** + * Every option needs its own distinct `value` — `GdSelect`'s (and the real `Select`'s) default + * `itemIdentifier` compares `selected?.value === item.value`. Omitting `value` leaves every + * option's `value` as `undefined`, so before anything is selected `undefined === undefined` is + * true for ALL of them at once, permanently rendering the whole list in the hover/selected color + * instead of just the pressed/selected row. + */ +const SELECT_ITEMS = [ + { name: 'Option 1', value: 'option-1' }, + { name: 'Option 2', value: 'option-2' }, + { name: 'Option 3', value: 'option-3' }, +]; + +/** + * Visual-fidelity verification harness for all 5 ported atoms (Task 7) — the RN analog of + * `libs/web-components/harness/fidelity-check.tsx`. Renders every atom with NO `theme` prop + * (i.e. each resolver's own hardcoded fallback), which is deliberate, not an oversight: per + * `libs/web-components/FINDINGS.md` Section 9, every one of these fallback values was already + * corrected to match the REAL `gd-design-library` token defaults. A themeless render here is + * therefore already the correct visual-fidelity baseline — hand-authoring a separate "RN-safe + * default theme" constant (Open Question 3 in the implementation plan) would just be a second, + * manually-kept-in-sync copy of the same values, the exact duplication risk Decision 1 already + * accepts once for the resolvers themselves; it should not be paid twice. + * + * IMPORTANT — this harness does not provide automated on-device verification: it only renders the + * components. See `react-native/FINDINGS.md` for what was and wasn't verified on simulator/device + * (including the captured screenshot) and what follow-ups remain. + */ +export default function App() { + const [checked, setChecked] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [selected, setSelected] = useState<{ name: string } | null>(null); + const [fontsLoaded] = useGdFonts(); + + // Held deliberately rather than rendering with the fallback face: RN does not re-measure text + // that already laid out in a different font, so painting before the faces register produces a + // visible reflow — and on a visual-fidelity harness specifically, a first frame in the *wrong + // typeface* is the exact failure this whole change exists to remove. + if (!fontsLoaded) { + return ( + + + + ); + } + + return ( + + GdButton + console.log('pressed')}> + Submit + + + GdCheckbox + + Accept terms + + + GdTypography + The quick brown fox jumps over the lazy dog. + + Small italic caption text + + + GdInput + + + GdSelect + + + + + ); +} + +const styles = StyleSheet.create({ + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#fff', + }, + container: { + flexGrow: 1, + backgroundColor: '#fff', + alignItems: 'stretch', + paddingHorizontal: 24, + paddingVertical: 48, + gap: 12, + }, +}); diff --git a/libs/react-native/FINDINGS.md b/libs/react-native/FINDINGS.md new file mode 100644 index 0000000..2f2f0ad --- /dev/null +++ b/libs/react-native/FINDINGS.md @@ -0,0 +1,404 @@ +# CTORNDSD-590 — React Native Integration Spike: Findings + +## Overall Verdict: GO (conditional) + +All 5 GridKit atoms (Button, Checkbox, Typography, Input, Select) are ported to React Native +(Expo SDK 51) inside `react-native/`, consuming `gd-design-core`'s shared token resolvers +and `zustand/vanilla` stores exactly as `libs/web-components`'s Lit port does for the same atoms +under sibling ticket CTORNDSD-581. The RN track reaches the same "5/5 atoms ported" completion +state, with 175 automated tests passing (121 `gd-design-core` resolver/store tests + 54 RN +interaction/adapter tests) and verified screenshots of all 5 atoms rendering on a booted iOS +Simulator. + +> **Update 2026-08-13.** Re-verifying this spike found and fixed a real rendering defect: every atom +> passed gd-design-core's **CSS font stack** into RN's `fontFamily`, which matches nothing on +> iOS/Android, so all five silently rendered in the system face. See _CSS-Shaped Token Values_ +> below. The pre-fix render is kept at `screenshots/ios-simulator-all-5-atoms.png` and the fixed one +> at `screenshots/ios-after-font-fix.png`. Two counts in the original text were also stale (it +> claimed 114 + 23 = 137 tests "across all 5 atoms", when Button in fact had none); Button now has +> 13 and the token adapters have 16. + +**Conditional on:** the interaction-level and cross-platform-fidelity claims below that this spike +could NOT verify empirically on-device — see "What Was NOT Verified" — must be confirmed by a +human with working simulator/device automation before this is a production go-ahead. This spike +verifies "does it work and look plausible," not "is every interaction behaviorally identical to +the Lit/React web adapters under real touch input." + +## Environment Note (read this before re-running anything below) + +The environment this spike was authored in had no simulator or device readily usable at the +start: `xcrun simctl list devices` hung indefinitely on first invocation (turned out to be a +one-time CoreSimulator runtime installation, not a permanent block) and no simulator device had +been created yet. After creating and booting an iOS 26.0 "iPhone 15" simulator: + +- `npx expo start --ios` (default LAN mode) opened Expo Go successfully but the app failed to load + with "Could not connect to the server" — the simulator's LAN-visible host IP was not reachable + from within this sandbox. +- `npx expo start --ios --localhost` fixed the connection (iOS Simulators share the host's network + stack directly, unlike physical devices, so `localhost` works where the LAN IP doesn't). +- Metro then failed to bundle: `Unable to resolve "gd-design-core"`. Fixed by adding + `react-native/metro.config.js` to alias `gd-design-core` to `../design-core/src` (via + `resolver.extraNodeModules`) and add that folder to `watchFolders` (Metro otherwise refuses to + read files outside `projectRoot`). +- Runtime then hit "Invalid hook call" / hooks dispatcher `null` due to two React copies + (root-hoisted `react` vs this package's pinned `react@18.2.0`). Fixed by forcing all `react` + imports (including subpaths like `react/jsx-runtime`) to resolve to this package's local React + via `resolver.resolveRequest`. + +**This is a real, concrete instance of the "Cascading tooling gap" the Lit spike's own +`FINDINGS.md` (Section 13) predicted for Metro specifically** — monorepo cross-project source +consumption requires explicit Metro resolver/watch-folder configuration; the fixed `metro.config.js` +is committed so a future contributor should not need to rediscover this. + +## Per-Atom Findings + +### 1. Button (pre-existing, from the seed commit — fixed here) + +The seeded `GdButton.tsx` had a live type error: its `toViewStyle` helper typed `borderWidth` as +a plain `number`, but `resolveButtonVariantStyle`'s `container.borderWidth` is actually a CSS +px-string (`'1px'`) — `DesignCoreTheme`-driven resolvers return `string | number` for every +size-shaped field, by design, so a themed override can supply either shape. Fixed by adding a +shared `pxToNumber()` utility (`src/utils/pxToNumber.ts`) that strips a trailing `px` and parses +the remainder; every atom below routes size-shaped resolver output through it before assigning to +an RN `style` prop. A second shared utility, `toFontWeight()` (`src/utils/toFontWeight.ts`), was +extracted from `GdButton.tsx`'s own local copy for the same reason — `fontWeight` is +`string | number` from the resolver, but RN's `TextStyle['fontWeight']` only accepts a fixed +string union. + +### 2. Checkbox + +Straightforward resolver+store port. `resolveCheckboxStyle` was recreated in `gd-design-core` +(see "Design-Core Resolver Recreation" below) and consumed as-is. `createCheckboxStore` needed +zero changes — it was already RN-ready (pure `zustand/vanilla`, no DOM assumptions). + +RN has no `indeterminate` DOM property to write to directly, unlike the Lit port's +`this._input.indeterminate = ...` direct-DOM-API write — there is no native checkbox element at +all in the RN port (`Pressable` + a drawn `View` indicator), so `indeterminate` only ever needs to +affect which icon renders and `accessibilityState.checked`'s `'mixed'` value. The check/ +indeterminate icon path data (`react-native-svg`, Decision 3) is copied verbatim from +`gd-checkbox.ts` for visual parity between the two spikes. + +**Verified**: unchecked/checked/indeterminate/disabled rendering and press-to-toggle behavior, via +5 automated interaction tests (`GdCheckbox.test.tsx`) and the simulator screenshot. +**Not verified**: real touch-target sizing/hit-slop feel on an actual finger-sized tap — no +device, only a simulator screenshot. + +### 3. Typography + +`resolveTypographyStyle` was recreated in `gd-design-core` unchanged in signature/behavior. +Mapped its flattened style object onto RN `Text`'s `style` prop; `textDecoration` becomes RN's own +`textDecorationLine` property name for the same CSS concept. + +**`'inherit'`-valued fields are omitted, not passed through** — RN has no CSS `inherit` keyword. +A nested `` already inherits unset style fields from its parent `` natively, which is +the correct RN-idiomatic equivalent for the `span` variant's whole point, not a gap needing a +workaround. Verified via `GdTypography.test.tsx`'s "omits inherit-valued style fields" test. + +**DOM-tag-polymorphism gap — confirmed, and stronger on RN than on Lit.** No `as` prop was +ported. `libs/web-components/FINDINGS.md` (Section 5, and its line-167 forward-reference) already +flagged this as unportable to Lit's fixed outer custom-element tag; RN's `Text` has no tag concept +at all — there is no DOM, no semantic-element vocabulary, nothing to swap. This is the RN-specific +writeup that ticket forward-referenced. Not a bug, a platform-structural fact: any consumer relying +on `as="h2"` semantics for accessibility/SEO on web has no RN equivalent to reach for; RN's own +accessibility model uses `accessibilityRole` instead, which `GdTypography` does not currently set +(a real gap worth a follow-on ticket if RN Typography moves beyond spike status). + +### 4. Input + +`resolveInputStyle` (plus its 4 exported color-variant constant maps) was recreated in +`gd-design-core` unchanged. `createInputStore` was consumed for `debounceCallbackTime`/`debounce` +only — see the two documented scope cuts below, both deliberate, both because the underlying +platform concept genuinely doesn't exist on RN, not because the port is incomplete: + +- **`isMouseInteraction` tracking (`registerMouseDown`/`registerKeyDown`) was NOT ported.** That + store state exists solely to pick a focus-ring style for mouse-vs-keyboard-Tab interaction — RN + has no mouse pointer or Tab-key focus-traversal convention on a touch target, so there is no RN + consumer for this value. +- **Cursor-jump mitigation was ported, using React's controlled-component model instead of the + Lit port's direct-DOM `activeElement` guard.** `GdInput` keeps its own `localValue` state as the + single rendered source of truth, gated by an `isFocusedRef`: an external `value` prop change is + only applied while NOT focused; a change arriving while focused is dropped and reconciled on + blur. This is architecturally the same guard as the Lit port's, translated to RN's state model + rather than a raw DOM write, since RN's `TextInput` has no `activeElement`/direct-value-write + escape hatch the way a native `` does. + +**Verified (automated, not on-device)**: typing updates the displayed value; `onValueChange` fires +immediately with no `debounceCallbackTime` set and is correctly debounced when one is set (fake +timers, exact call count/timing asserted); the cursor-jump guard's _logic_ — an external +`rerender()` with a new `value` prop while focused is dropped, then applied on blur — passes under +`@testing-library/react-native`'s `fireEvent`/`rerender` harness (`GdInput.test.tsx`). + +**NOT verified on-device, and this matters**: `@testing-library/react-native`'s `fireEvent.focus`/ +`fireEvent.blur`/`rerender` are synchronous JS-level simulations. They prove the guard's _code +path_ is exercised correctly under a scripted sequence, but they cannot reproduce the actual +adversarial scenario the Lit port's own FINDINGS.md (Section 4) empirically tested: real +asynchronous lag between a keystroke and a state-driving re-render, on a real `TextInput` with a +real native text-editing cursor and IME/autocorrect interactions (visible in the simulator +screenshot: the sample "Do" text shows iOS's native autocorrect underline, which is exactly the +kind of native-input behavior a JS-level test can't reach). **This is an open risk, not a closed +one** — a human with a real device or working simulator-automation harness must still type under +artificial lag on an actual `TextInput` before this can be called verified, matching the rigor bar +the Lit spike set for itself. + +### 5. Select + +Reduced-scope PoC (single-select, no search, fixed-below positioning) — mirroring the Lit port's +own reduced scope (`gd-select.ts`'s doc comment), for the same reason: this is a spike, not a +production parity claim. `createSelectStore` needed zero changes (already RN-ready). A brand-new +`resolveSelectStyle` was added to `gd-design-core` (the Lit port deleted the old one outright +rather than gutting it to a stub, so there was nothing to "recreate" — this is new code, sourced +from the pre-deletion commit `74c1ea6`). The chevron icon path data is copied verbatim from +`gd-select.ts` for the same visual-parity reason as Checkbox's icons. + +`boxShadow` (a CSS shadow string from the resolver, e.g. +`'0px 8px 15px 1px rgba(0, 0, 0, 0.20)'`) is **approximated with a static RN shadow/elevation +pair, not parsed** — writing a CSS box-shadow string parser into per-platform shadow props +(`shadowColor`/`shadowOffset`/`shadowOpacity`/`shadowRadius` on iOS, `elevation` on Android) is +deferred as a documented gap, out of scope for a spike. + +## CSS-Shaped Token Values (found and fixed 2026-08-13) + +`gd-design-core`'s resolvers return **web CSS values** for every style-shaped field. That is +invisible to a CSS-native consumer like the Lit port and load-bearing for React Native. Four +instances, in the order they were discovered: + +| Token shape | Example | Status | +| ------------------------------ | ---------------------------- | ------------------------------------------------------- | +| px string | `'1px'`, `'16px'` | **Adapted** — `pxToNumber()` (original spike) | +| font weight `string \| number` | `500` | **Adapted** — `toFontWeight()` (original spike) | +| font stack | `'"Fira Sans", sans-serif'` | **Adapted** — `toFontFamily()` + `fonts.ts` (this pass) | +| `box-shadow` string | `'0px 8px 15px 1px rgba(…)'` | **Still approximated** — static shadow/elevation pair | + +### The font defect + +Every text-bearing atom did `fontFamily: resolved.fontFamily as string`. RN's `fontFamily` on +iOS/Android is a single key into the native font registry, not a CSS stack, so `"Fira Sans", +sans-serif` matched nothing and the platform fell back silently — no warning, no error, and a +green test suite. `react-native-web` masked it completely, because there the value is real CSS and +`dist/libs/ui/styles.css` had already pulled Fira Sans from Google Fonts. The `as string` cast was +the tell: it suppressed exactly the type mismatch that would have surfaced this. + +Underneath that sat a second layer — the typeface had never been bundled. There was no `expo-font` +usage, no `useFonts`, and no font assets, so even the correct bare name `Fira Sans` would have +failed. + +**The fix is two files, deliberately split so the pure half is testable without asset resolution:** + +- `src/utils/toFontFamily.ts` — parses the stack to a family, then resolves family + weight + + italic to a concrete face name. RN does not synthesize weights from one family the way CSS does; + each weight and italic is a separately registered face, so the weight has to be part of the + lookup. Unregistered weights snap to the nearest registered one (ties round down), so the family + stays correct even where the weight is approximated. +- `src/fonts.ts` — registers those faces via `expo-font`; `useGdFonts()` is consumed by `App.tsx`, + which holds first paint until the faces load (RN does not re-measure text already laid out in the + fallback face). + +The two files share their face tables, and two tests assert they never drift in either direction — a +face that can be selected but is never loaded reproduces this bug exactly, one weight at a time. + +Weight coverage is intentionally partial (Fira Sans 300/400/500/700 + 400/700 italic, Fira Code +400): those are the only weights gd-design-core emits, and each TTF is ~430 kB. Cost of the fix is +**7 TTFs / 2.9 MB** added to the iOS bundle. + +**Verified:** web resolves every text node to a loaded face with no stack remaining +(`document.fonts` confirms all 7 registered); the iOS bundle exports all 7 TTFs; and the fixed +render was confirmed on a booted iPhone 16 Simulator — Fira Sans throughout, with a true italic face +on the caption and the 500 face on the button label. **Not verified:** anything preventing +regression, since that check was a human looking at a simulator rather than a harness. Android +remains unbooted. + +### Why this matters beyond the bug + +The token values assume a CSS consumer. Three per-platform adapters now exist purely to undo that +assumption, and a fourth case (`box-shadow`) is still unhandled. Whether that adapter layer is the +intended architecture or an accumulating tax is a design-token decision, not a React Native one — +and it is the question this spike most wants answered. + +## Tooling Defect: root `dev:react-native` swallowed its flags (fixed 2026-08-13) + +`"dev:react-native": "npm run start --workspace=libs/react-native"` did not forward arguments. +`npm run dev:react-native -- --ios --localhost` appended the flags to the _inner_ `npm run` +invocation, which parsed them as npm config and warned `Unknown cli config "--ios"`; Expo then +started in default LAN mode. Since this README documented that exact command as the fix for the +"Could not connect to the server" failure recorded in the Environment Note above, the documented +workaround silently did nothing. + +Fixed by terminating the root script with `--` so appended args reach `expo start`. Verified: +`npm run dev:react-native -- --web --port 5475` now runs `expo start --web --port 5475` with no +config warnings. + +## Select Approach Evaluation (Decision 2) + +RN has no `popover`/CSS-anchor-positioning equivalent, so the Lit port's `popover="auto"` + +manual `getBoundingClientRect()` approach doesn't translate directly. Two candidate approaches +were built for comparison, per the implementation plan's explicit instruction to evaluate rather +than assume an answer (mirroring the Lit spike's own Section 6 methodology): + +**Option A — RN `Modal` (shipped as the default, in `GdSelect.tsx`):** `Modal`'s own chrome +gives Android hardware-back-button dismiss (`onRequestClose`) and correct full-screen overlay +stacking through the native window layer, independent of where in the component tree it's +mounted. Cost: one `measureInWindow()` call on open to anchor the dropdown under the trigger, +since `Modal` has no "anchor to element" concept built in. + +**Option B — custom absolutely-positioned `View`, no `Modal` (prototype only, in +`GdSelectAnchoredPrototype.tsx`, NOT shipped):** A load-bearing limitation was found while +building this, not hypothesized in advance: **RN's `position: 'absolute'` resolves against the +nearest positioned ancestor, not the device viewport** — there is no `fixed`-to-window equivalent +outside `Modal`'s native window layer. This means Option B's backdrop/dropdown only visually +covers the true screen when the component (or an ancestor) is mounted with `flex: 1` at or near +the app root; nested inside any scrollable list, padded card, or `overflow: 'hidden'` container, +the backdrop clips to that container instead of the screen. Option B also has no Android +hardware-back-button dismiss without adding a native `BackHandler` listener by hand, and no +guaranteed paint-order elevation above sibling content beyond JSX ordering. + +**Verdict: Option A (Modal) wins on engineering merit** — it has no mounting-position +requirement and gets back-button dismiss for free. This was a **reasoned engineering judgment +based on building both prototypes and reading RN's own layout model, not an empirical on-device +comparison** — neither approach's actual dismiss-on-outside-tap, dismiss-on-back-button, +rotation-repositioning, or paint-flicker behavior was exercised on a real simulator/device (no +touch-automation tool was available in this environment; see "What Was NOT Verified"). A human +should still tap through both on-device before fully retiring Option B's code, in case some +device-specific `Modal` quirk (there are known historical Android `Modal` transparency/keyboard- +avoidance issues in RN) tips the balance back. + +A secondary, accepted trade-off in the shipped `GdSelect.tsx`: `isOpen` (the store's own state) +and `triggerLayout` (this component's own position state) are deliberately decoupled — the store +opens immediately on press, and `measureInWindow`'s async result only ever refines the dropdown's +position once it resolves, defaulting to `{x:0, y:0, ...}` until then. An earlier revision gated +`store.getState().open()` itself inside the `measureInWindow` callback; that coupling turned out +to have no guaranteed timing (and, discovered while writing this component's own tests, no +guaranteed firing at all inside a JS-only test renderer — RN's jest preset mocks +`measureInWindow` as a no-op `jest.fn()` per host-component instance). The decoupled version is +both more testable and more robust on a real device against a stalled/slow native bridge call. +Accepted cost: the dropdown may render one frame at `(0,0)` before repositioning — a minor, +documented flicker risk, not verified on-device. + +## Design-Core Resolver Recreation (Decision 1) + +`gd-design-core/src/tokenResolvers/{checkbox,input,typography}.ts` had their `resolveXStyle` +functions deleted by CTORNDSD-581 in favor of the Lit adapter importing `gd-design-library/tokens` +directly + `resolveThemeTree` ("true single source of truth" — see those files' git history, +commit `7e47cec`). `select.ts` didn't exist at all post-deletion. `resolveButtonVariantStyle` was +the one resolver spared from that deletion, explicitly because it's "still shared with +`react-native`'s `GdButton`" (that ticket's own commit message/doc comments). + +This spike recreates the other 4 resolvers (`checkbox`, `input`, `typography` restored; +`select` newly added) from their last-known-good, already-token-corrected pre-deletion source +(commit `74c1ea6`), because `react-native` has no DOM/CSS runtime to resolve +`gd-design-library/tokens` + `resolveThemeTree` the way the Lit adapter does, and Metro's +tooling gap (see "Environment Note") makes taking on `gd-design-library`'s own dependency tree +(Emotion, DOM-oriented asset imports) a materially bigger lift than recreating ~150 lines of +already-correct resolver code. + +**Accepted, documented trade-off — this is a real duplication, not resolved, only recorded**: an +edit to `libs/ui/src/tokens/{checkbox,input,select,typography}.ts` will **not** automatically +propagate to these RN resolvers, unlike the Lit adapter's direct-import approach. Every recreated +resolver file carries a doc comment stating this explicitly and pointing back to this file. Do +not delete these resolvers again under the "single source of truth" reasoning that removed them +the first time without checking whether `react-native` still consumes them. + +## Not Applicable to RN (recorded, not silently omitted) + +The Lit spike's `FINDINGS.md` covers several verification categories that have no RN equivalent +at all: + +- **Shadow-DOM isolation (CTORNDSD-286)** — RN has no Shadow DOM or CSS-scoping concept; there is + no analogous isolation boundary to test. +- **SSR/Declarative Shadow DOM hydration** — RN apps don't server-render into a DOM; there is no + hydration step to compare. +- **Gzip bundle-size ratio vs. the original React component** — Metro's bundle output model + (single JS bundle, Hermes bytecode compilation, no per-component gzip granularity the way a + web bundler's code-splitting does) doesn't produce a directly comparable number without a + dedicated RN bundle-analysis setup, which is out of scope for this spike. +- **React 19 event-mapping / native-property-assignment heuristics** — this is a web-specific + React-DOM reconciler concern (custom-element boolean-attribute vs. JS-property heuristics); + RN's reconciler talks to native views through its own bridge, not this mechanism. + +## What Was NOT Verified (read before treating this as a closed spike) + +1. **On-device/simulator interactive behavior** for all 5 atoms — actual finger-tap feel, real + focus/blur timing, real IME/autocorrect interaction (partially visible by accident in the + screenshot), IS running (see the screenshot), but no touch/type automation tool was available + in this environment to script interactions against it, so only a static initial-render + screenshot was captured, not an interaction recording. +2. **The cursor-jump adversarial scenario** (Input, Section under "Per-Atom Findings" above) — + implemented and covered by a scripted JS-level approximation, not an on-device test with real + async lag. +3. **The Select approach comparison's actual dismiss/rotation/flicker behavior** — both options + were built and reasoned about, neither was tapped through on a real device. +4. **Cross-platform visual-parity comparison** — the screenshots prove the RN atoms render + plausibly and match the intended tokens' values (verified: yellow primary button fill, + correct checkbox border color, correct heading/body/caption type scale, bordered input with + label/helper text, bordered select trigger with chevron), but no pixel-level or side-by-side + comparison against the real Storybook components or the Lit atoms was performed. + + **This item is how the font defect survived.** The original pass checked the type _scale_ and + recorded it as correct, which it was — while the _typeface_ was silently wrong in the same + screenshot. A parity check that compares metrics but not rendered glyphs cannot catch a font + fallback. Fixed and re-verified on device (see _CSS-Shaped Token Values_), but the general point + stands: "looks plausible" is not parity, and this list's own wording proved it. + +5. **Android** — only an iOS Simulator was exercised; nothing here has been checked on the Android + emulator, despite one being present in this environment (`~/Library/Android/sdk/tools/emulator`) + — booting and provisioning an AVD, then repeating the above, was out of time budget for this + pass. + +## Conditions on the Verdict + +- A human must complete items 1-5 above (or a follow-on ticket must scope proper RN + device-automation tooling — see Follow-on Tickets) before this spike's "GO" is unconditional. +- The Decision-1 resolver-duplication trade-off must be re-confirmed as acceptable whenever + `libs/ui/src/tokens/{checkbox,input,select,typography}.ts` changes — there is no automated + drift detector between the two copies. +- `boxShadow` string parsing remains unimplemented; any visual QA pass should expect Select's + dropdown shadow to be an approximation, not a match. + +## Scope Confirmation + +**Touched**: `libs/design-core/src/tokenResolvers/{checkbox,input,typography}.ts` (resolver +bodies restored), `libs/design-core/src/tokenResolvers/select.ts` (new), +`libs/design-core/src/tokenResolvers/{checkbox,input,select,typography}.spec.ts` (restored/new), +`libs/design-core/src/tokenResolvers/index.ts` (barrel exports), `libs/design-core/README.md` +(status), `react-native/**` (all 5 atom components, tests, `App.tsx`, `metro.config.js`, +`package.json`, `README.md`). + +**Touched in the 2026-08-13 pass** (font fix + test gap): `react-native/src/utils/toFontFamily.ts` +and its test (new), `react-native/src/fonts.ts` (new), `react-native/src/types/assets.d.ts` (new), +`react-native/src/components/GdButton/GdButton.test.tsx` (new — the atom that had no tests), +`react-native/src/components/{GdButton,GdInput,GdSelect,GdTypography}/*.tsx` (route `fontFamily` +through the adapter), `react-native/App.tsx` (gate first paint on `useGdFonts()`), +`react-native/package.json` (`@expo-google-fonts/*`), and the root `package.json` +(`dev:react-native` flag forwarding). `gd-design-core` was **not** modified — the CSS-shaped values +are adapted on the RN side, deliberately leaving the token-architecture decision open. + +**Explicitly NOT touched**: `libs/ui` (`gd-design-library`) source, build config, or shipped +output; `libs/web-components/*` (any file — that spike is already complete and this ticket does +not reopen it); `libs/design-core/src/stores/*` (all 3 stores consumed unmodified); `tsconfig.base.json`; +`nx.json`. (Root `package.json` _was_ updated to add the `libs/react-native` workspace and a `dev:react-native` script.) + +## Follow-on Tickets (not this ticket's scope) + +- Stand up a real RN device-automation harness (Detox, Maestro, or an Appium/WebDriverIO RN + driver) so future spikes/PRs on this track get the same "verified live" rigor the Lit spike had + via `chrome-devtools-mcp` — this is the single biggest gap this spike's own verification hit. +- Implement a CSS `boxShadow`-string → per-platform shadow-prop parser (shared utility, would also + benefit any future RN atom with a shadow token). This is the last unhandled CSS-shaped token value + — see _CSS-Shaped Token Values_ for the other three and why the category matters more than the + instances. +- Decide the design-token question the font defect exposed: do token values become + platform-neutral, or are per-platform adapters (`pxToNumber`, `toFontWeight`, `toFontFamily`, and + a future shadow parser) the accepted permanent architecture? +- Trim the font bundle. The fix adds 2.9 MB of TTFs for 7 faces. Subsetting, or dropping the + weights nothing currently requests, is a real saving nobody has measured. +- Full `GdSelect` parity (multi-select, search filtering, full keyboard/focus-traversal) — the + underlying `createSelectStore` already supports multi-select and search; only the two adapters' + own reduced implementation scope is the limiter. +- Android emulator pass — repeat the simulator verification on `~/Library/Android/sdk`'s emulator; + RN `Modal`'s Android-specific quirks (historical transparency/keyboard-avoidance issues) make + this a real risk area for the Select approach decision above, not a formality. +- Extrapolated full-catalog migration-cost estimate for RN (mirroring the Lit spike's own + "rough order-of-magnitude, not a committed plan" framing) — needs its own scoping pass, same as + that ticket's equivalent section states for itself. +- `GdTypography`'s missing `accessibilityRole`/semantic-heading equivalent — RN has no `as` prop + to port, but `accessibilityRole="header"` (or similar) for heading variants is a real, + addressable a11y gap this spike didn't close. diff --git a/libs/react-native/README.md b/libs/react-native/README.md new file mode 100644 index 0000000..b7d88be --- /dev/null +++ b/libs/react-native/README.md @@ -0,0 +1,115 @@ +# react-native (CTORNDSD-590) + +Disposable React Native (Expo) PoC. See `FINDINGS.md` for the spike's verdict, per-atom findings, +the Select dropdown-presentation approach evaluation, and what was and wasn't verified on-device. + +All 5 GridKit atoms are ported: `GdButton`, `GdCheckbox`, `GdTypography`, `GdInput`, `GdSelect` +(each under `src/components//`), all consuming `gd-design-core`'s shared token resolvers and +`zustand/vanilla` stores — the same shared core `libs/web-components`'s Lit port (CTORNDSD-581) +consumes for the same 5 atoms. + +## Per-component quick reference + +| Component | Key props (all also take `theme`) | Callback | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `GdButton` | `variant` (`primary`\|`secondary`\|`tertiary`\|`outlined`\|`text`\|`inherit`), `disabled`, `isLoading` | `onPress` | +| `GdCheckbox` | `checked`, `indeterminate`, `disabled`, `size` (`sm`\|`md`) | `onValueChange(checked)` | +| `GdTypography` | `variant` (`span`\|`h1`–`h6`\|`p`\|`small`\|`caption`\|`header`\|`code`\|`kbd`), `styleVariant` (single/array) | — (renders `children` as `Text`) | +| `GdInput` | `value`, `placeholder`, `label`, `helperText`, `disabled`, `color` (`primary`\|`success`\|`warning`\|`error`), `debounceCallbackTime` | `onValueChange(value)` | +| `GdSelect` | `items` (array), `value`, `disabled`, `color`, `placeholder`, `emptyLabel` | `onValueChange(value)` | + +No `as` prop on `GdTypography` (RN's `Text` has no DOM-tag concept to swap — see `FINDINGS.md`). +`GdSelect` is reduced-scope: single-select, no search, fixed-below positioning only. + +## Setup + +`gd-design-core` is resolved straight to its TS source (`libs/design-core/src`) via a workspace +dependency (`"gd-design-core": "*"`) plus a `resolver.extraNodeModules` alias in +`metro.config.js` — the same single-source-of-truth approach `libs/web-components`'s vite config +uses for `gd-design-library`. No build step in between; editing a token resolver is reflected +immediately. + +```bash +npm install # from the repo root (npm workspaces) — REQUIRED, see note below +cd libs/react-native +npm run type-check +npm test # 54 tests across all 5 atoms + token adapters (jest-expo + RNTL) +``` + +`npm install` at the **repo root** is not optional. On a stale `node_modules`, `tsc` emits hundreds +of errors (`Cannot use JSX unless the '--jsx' flag is provided`, `Cannot find module +'react-native'`) that all cascade from one cause: `expo/tsconfig.base` is unresolvable because +`expo` is not installed. + +Workspace hoisting gives `react-test-renderer` and `@testing-library/react-native` (no version +conflict, so npm hoists them to the repo root) a _different_ `react` module instance than this +package's own pinned `react@18.2.0` (kept local because it conflicts with the root's +`react@^18.3.1`) — two React copies means two hooks dispatchers, so `useState` reads `null` +inside `act()`. Fixed with a Jest `moduleNameMapper` forcing every `react` import back to this +package's own `node_modules/react` (see `package.json`'s `jest` config). + +This package also depends on `zustand` (required by `gd-design-core` at runtime), +`react-native-svg` (Checkbox's check/indeterminate icons, Select's chevron — see `FINDINGS.md` +Decision 3), and `@expo-google-fonts/fira-sans` + `@expo-google-fonts/fira-code` (the actual +typeface files — see _Fonts_ below). + +### Fonts + +`gd-design-core` returns `fontFamily` as a **web CSS stack** (`'"Fira Sans", sans-serif'`). RN's +`fontFamily` on iOS/Android is a single native-registry lookup key, not CSS, so that string matches +nothing and the platform silently falls back to the system face. Two pieces close this: + +- `src/utils/toFontFamily.ts` — parses the stack and resolves family + weight + italic to a + concrete face name (`FiraSans_500Medium`), snapping unregistered weights to the nearest + registered one. Pure and unit-tested. +- `src/fonts.ts` — registers those faces with `expo-font`. `useGdFonts()` returns + `[loaded, error]`; **hold first paint until `loaded`**, as `App.tsx` does, because RN does not + re-measure text that already laid out in the fallback face. + +The two files share their face tables, and `toFontFamily.test.ts` asserts they never drift — a face +that can be _selected_ but is never _loaded_ reproduces the original bug exactly. + +Weight coverage is deliberately partial (300/400/500/700 plus 400/700 italic, and Fira Code 400): +gd-design-core emits only those weights, and each TTF is ~430 kB. Adding a weight means adding it to +both files. + +### Metro config is required, not optional + +`metro.config.js` aliases `gd-design-core` to `../design-core/src` via `resolver.extraNodeModules` +and adds that path to `watchFolders` (Metro refuses to read files outside `projectRoot` +otherwise). Do not remove this file. `FINDINGS.md`'s "Environment Note" documents the earlier +`file:../dist/libs/design-core` + symlink/exports-map setup this replaced, and the failure +sequence it was debugged from. + +### Running on a simulator + +From the repo root (same pattern as `npm run dev:web-components`): + +```bash +npm run dev:react-native # Expo dev server +# then press `i` for iOS or `a` for Android, or scan the QR code with Expo Go on a physical device +``` + +Or from this package directly: `cd libs/react-native && npm run start`. + +If the simulator reports "Could not connect to the server" using the default LAN URL, restart with +`npm run dev:react-native -- --ios --localhost` — iOS Simulators share the host's network stack +directly, so `localhost` works in environments where the LAN-visible IP doesn't (see +`FINDINGS.md`). + +Flag forwarding through the root script only works because `dev:react-native` ends in `--`. Without +it, npm parses `--ios`/`--localhost` as its own config, warns `Unknown cli config "--ios"`, and +starts Expo in default LAN mode — i.e. the documented workaround silently did nothing. If you see +that warning, your checkout predates the fix; use +`cd libs/react-native && npx expo start --ios --localhost`. + +## Workspace membership + +This package lives at `libs/react-native`, alongside `libs/design-core` and +`libs/web-components`, and is listed in the root `package.json`'s `workspaces` array — the same +setup `libs/web-components` uses. Its `package.json` name is `gd-react-native` (not `react-native`) +so npm's workspace symlinking doesn't collide with the real `react-native` npm dependency. + +This package is the sibling React Native track under epic CTORNDSD-580 ("Add webcomponents +support"), alongside `libs/web-components`'s Lit/Web-Components track (CTORNDSD-581). Both consume +the same `gd-design-core` shared resolvers/stores for the same 5 atoms. diff --git a/spike-react-native/app.json b/libs/react-native/app.json similarity index 79% rename from spike-react-native/app.json rename to libs/react-native/app.json index 59535af..37d3a4e 100644 --- a/spike-react-native/app.json +++ b/libs/react-native/app.json @@ -1,7 +1,7 @@ { "expo": { - "name": "spike-react-native", - "slug": "spike-react-native", + "name": "react-native", + "slug": "react-native", "version": "0.0.0", "orientation": "portrait", "userInterfaceStyle": "automatic", diff --git a/spike-react-native/babel.config.js b/libs/react-native/babel.config.js similarity index 100% rename from spike-react-native/babel.config.js rename to libs/react-native/babel.config.js diff --git a/libs/react-native/index.js b/libs/react-native/index.js new file mode 100644 index 0000000..23eee16 --- /dev/null +++ b/libs/react-native/index.js @@ -0,0 +1,5 @@ +import registerRootComponent from 'expo/build/launch/registerRootComponent'; + +import App from './App'; + +registerRootComponent(App); diff --git a/libs/react-native/metro.config.js b/libs/react-native/metro.config.js new file mode 100644 index 0000000..debba59 --- /dev/null +++ b/libs/react-native/metro.config.js @@ -0,0 +1,55 @@ +const path = require('path'); +const { getDefaultConfig } = require('expo/metro-config'); + +const config = getDefaultConfig(__dirname); + +/** + * `gd-design-core` is resolved straight to its TS source (`libs/design-core/src`), the same + * single-source-of-truth approach `libs/web-components`'s vite config uses for `gd-design-library` + * (see that project's `vite.config.ts` dev-server alias) — no build step in between, and editing a + * token resolver is reflected immediately. Metro has no tsconfig-paths awareness, so the mapping + * is restated here via `resolver.extraNodeModules`, mirroring `tsconfig.json`'s `paths` entry. + */ +const designCoreSrc = path.resolve(__dirname, '../design-core/src'); +config.resolver.extraNodeModules = { + ...config.resolver.extraNodeModules, + 'gd-design-core': designCoreSrc, +}; +// Metro refuses to *read* files outside `projectRoot` unless they fall under an explicit +// `watchFolders` entry, even once the resolver is willing to point there. Narrowly scoped to the +// one source tree actually consumed — NOT the whole monorepo root, which would pull the main +// repo's own `node_modules` (a second React/RN copy, duplicate haste-module names) into Metro's +// watch/crawl set. +// `dist/libs/ui` is the same kind of narrowly-scoped exception, for the same reason — App.tsx's +// harness-only `dist/libs/ui/styles.css` import (real Fira Sans font + reset, for visual-fidelity +// comparison against the real gd-design-library web render) needs it, and it doesn't exist +// otherwise. +const uiDist = path.resolve(__dirname, '../../dist/libs/ui'); +config.watchFolders = [...(config.watchFolders ?? []), designCoreSrc, uiDist]; + +/** + * `react-native` itself is hoisted to the monorepo root (no version conflict there, unlike + * `react`), so its internals resolve `require('react')` via normal hierarchical node_modules + * lookup starting from `/node_modules/react-native`, landing one directory up on the + * root's `react@18.3.1` — a *different* copy than the one this package's own pinned + * `react@18.2.0` resolves to for `App.tsx`/GdButton components. Two React copies means two hooks + * dispatchers, causing "Invalid hook call" / "Cannot read property 'useState' of null" at runtime + * (the same root cause the README's Jest `moduleNameMapper` fixes for tests). + * + * `resolver.extraNodeModules` can't fix this — it's a fallback consulted only when normal + * hierarchical lookup *fails*, and lookup for `react` always succeeds (at the root copy) before + * reaching it. `resolver.resolveRequest` is the actual override hook, so intercept `react` and + * its subpath imports (e.g. `react/jsx-runtime`) here and force them to this package's own copy, + * regardless of which file in the dependency graph is requiring them. + */ +const localReactDir = path.resolve(__dirname, 'node_modules/react'); +const defaultResolveRequest = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (moduleName === 'react' || moduleName.startsWith('react/')) { + const redirected = path.join(localReactDir, moduleName.slice('react'.length)); + return (defaultResolveRequest ?? context.resolveRequest)(context, redirected, platform); + } + return (defaultResolveRequest ?? context.resolveRequest)(context, moduleName, platform); +}; + +module.exports = config; diff --git a/libs/react-native/package.json b/libs/react-native/package.json new file mode 100644 index 0000000..dfadcb3 --- /dev/null +++ b/libs/react-native/package.json @@ -0,0 +1,46 @@ +{ + "name": "gd-react-native", + "version": "0.0.0", + "private": true, + "description": "CTORNDSD-590 spike: disposable React Native PoC for gd-design-library's atoms. Not published.", + "main": "index.js", + "scripts": { + "start": "expo start", + "type-check": "tsc --noEmit", + "test": "jest" + }, + "dependencies": { + "@expo-google-fonts/fira-code": "0.2.3", + "@expo-google-fonts/fira-sans": "0.2.3", + "@expo/metro-runtime": "~3.2.3", + "expo": "~51.0.28", + "expo-status-bar": "~1.12.1", + "gd-design-core": "*", + "react": "18.2.0", + "react-native": "0.74.5", + "react-native-svg": "15.2.0", + "react-native-web": "~0.19.10", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@babel/core": "^7.24.0", + "@testing-library/react-native": "^12.9.0", + "@types/jest": "^29.5.14", + "@types/react": "~18.2.79", + "babel-preset-expo": "~11.0.0", + "jest": "^29.4.0", + "jest-expo": "~51.0.4", + "react-test-renderer": "^18.2.0", + "typescript": "~5.3.3" + }, + "jest": { + "preset": "jest-expo", + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)" + ], + "moduleNameMapper": { + "^react$": "/node_modules/react", + "^react/(.*)$": "/node_modules/react/$1" + } + } +} diff --git a/libs/react-native/screenshots/ios-after-font-fix.png b/libs/react-native/screenshots/ios-after-font-fix.png new file mode 100644 index 0000000..343f642 Binary files /dev/null and b/libs/react-native/screenshots/ios-after-font-fix.png differ diff --git a/libs/react-native/screenshots/ios-simulator-all-5-atoms.png b/libs/react-native/screenshots/ios-simulator-all-5-atoms.png new file mode 100644 index 0000000..ef15591 Binary files /dev/null and b/libs/react-native/screenshots/ios-simulator-all-5-atoms.png differ diff --git a/libs/react-native/screenshots/rn-web-render.png b/libs/react-native/screenshots/rn-web-render.png new file mode 100644 index 0000000..b3abd04 Binary files /dev/null and b/libs/react-native/screenshots/rn-web-render.png differ diff --git a/libs/react-native/src/components/GdButton/GdButton.test.tsx b/libs/react-native/src/components/GdButton/GdButton.test.tsx new file mode 100644 index 0000000..e3a1aa9 --- /dev/null +++ b/libs/react-native/src/components/GdButton/GdButton.test.tsx @@ -0,0 +1,152 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { ActivityIndicator, Pressable, StyleSheet, type TextStyle, type ViewStyle } from 'react-native'; +import { GdButton } from './GdButton'; +import { FIRA_SANS_FACES } from '../../utils/toFontFamily'; + +type FlatStyle = ViewStyle & TextStyle; + +/** Pressable's `style` is a `({ pressed }) => ViewStyle[]` function, so the host node carries an + * array rather than a plain object. Flatten before asserting. */ +const flat = (style: unknown): FlatStyle => (StyleSheet.flatten(style as never) ?? {}) as FlatStyle; +const styleOf = (node: { props: { style?: unknown } }): FlatStyle => flat(node.props.style); + +describe('GdButton', () => { + it('renders its children as the button label', () => { + render(Submit); + expect(screen.getByText('Submit')).toBeTruthy(); + expect(screen.getByRole('button')).toBeTruthy(); + }); + + it('fires onPress when pressed', () => { + const onPress = jest.fn(); + render(Submit); + + fireEvent.press(screen.getByRole('button')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not fire onPress when disabled, and reports it to assistive tech', () => { + const onPress = jest.fn(); + render( + + Submit + + ); + + const button = screen.getByRole('button'); + expect(button.props.accessibilityState).toMatchObject({ disabled: true }); + + fireEvent.press(button); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('swaps the label for a spinner while loading, and blocks presses', () => { + const onPress = jest.fn(); + render( + + Submit + + ); + + // `isLoading` implies disabled — a button mid-request must not accept a second press. + expect(screen.queryByText('Submit')).toBeNull(); + expect(screen.UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + expect(screen.getByRole('button').props.accessibilityState).toMatchObject({ busy: true, disabled: true }); + + fireEvent.press(screen.getByRole('button')); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('applies the resolved primary variant container color', () => { + render(Submit); + expect(styleOf(screen.getByRole('button')).backgroundColor).toBe('#FFB800'); + }); + + it('applies the disabled container color over the variant color', () => { + render( + + Submit + + ); + expect(styleOf(screen.getByRole('button')).backgroundColor).toBe('#E5E5E5'); + }); + + it('swaps to the active container color while pressed', () => { + // Pressable's pressed state lives inside RN's Pressability responder machinery, which + // `fireEvent` does not drive — so exercise the `({ pressed }) => style` function directly. + // That function is the part this component actually owns. + render(Submit); + const styleFn = screen.UNSAFE_getByType(Pressable).props.style; + + expect(flat(styleFn({ pressed: false })).backgroundColor).toBe('#FFB800'); + expect(flat(styleFn({ pressed: true })).backgroundColor).toBe('#FF8700'); + }); + + it('does not apply the active color while disabled', () => { + render( + + Submit + + ); + const styleFn = screen.UNSAFE_getByType(Pressable).props.style; + + expect(flat(styleFn({ pressed: true })).backgroundColor).toBe('#E5E5E5'); + }); + + it("splits the resolver's two-value CSS padding shorthand into RN's separate axes", () => { + // `resolved.padding` is the string '8px 16px'; RN has no shorthand padding string. + render(Submit); + const style = styleOf(screen.getByRole('button')); + + expect(style.paddingVertical).toBe(8); + expect(style.paddingHorizontal).toBe(16); + }); + + it('converts a CSS px-string borderWidth to the number RN requires', () => { + // The seeded type bug: `container.borderWidth` is '1px', not 1. Outlined is the variant that + // actually carries one. + render(Submit); + const style = styleOf(screen.getByRole('button')); + + expect(style.borderWidth).toBe(1); + expect(typeof style.borderWidth).toBe('number'); + expect(style.borderColor).toBe('#000000'); + }); + + it('renders the label with a loadable font face, never a CSS font stack', () => { + // Regression guard for the CTORNDSD-590 font defect: the resolver returns + // '"Fira Sans", sans-serif', which matches no native family. The button label uses the + // medium (500) face, per `resolved.label.fontWeight`. + render(Submit); + const labelStyle = styleOf(screen.getByText('Submit')); + + expect(labelStyle.fontFamily).toBe(FIRA_SANS_FACES[500]); + expect(labelStyle.fontFamily).not.toContain(','); + expect(labelStyle.fontWeight).toBe('500'); + expect(labelStyle.fontSize).toBe(16); + expect(labelStyle.color).toBe('#000000'); + }); + + it('shows a focus ring on focus and removes it on blur', () => { + // `fireEvent(node, 'focus')` does not reach Pressable's forwarded handler under RNTL, so + // invoke the prop the component supplied. + render(Submit); + + expect(screen.queryByTestId('gd-button-focus-ring')).toBeNull(); + + act(() => screen.UNSAFE_getByType(Pressable).props.onFocus()); + expect(screen.getByTestId('gd-button-focus-ring')).toBeTruthy(); + + act(() => screen.UNSAFE_getByType(Pressable).props.onBlur()); + expect(screen.queryByTestId('gd-button-focus-ring')).toBeNull(); + }); + + it('honours a theme override instead of the resolver fallback', () => { + render( + + Submit + + ); + expect(styleOf(screen.getByRole('button')).backgroundColor).toBe('#123456'); + }); +}); diff --git a/libs/react-native/src/components/GdButton/GdButton.tsx b/libs/react-native/src/components/GdButton/GdButton.tsx new file mode 100644 index 0000000..64ac891 --- /dev/null +++ b/libs/react-native/src/components/GdButton/GdButton.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react'; +import { ActivityIndicator, Pressable, Text, View, type GestureResponderEvent, type ViewStyle } from 'react-native'; +import { + resolveButtonRadius, + resolveButtonVariantStyle, + type ButtonVariantName, + type DesignCoreTheme, +} from 'gd-design-core'; +import { pxToNumber } from '../../utils/pxToNumber'; +import { toFontFamily } from '../../utils/toFontFamily'; +import { toFontWeight } from '../../utils/toFontWeight'; + +/** Picks only the ViewStyle-compatible fields — gd-design-core's `color` field is for text, not + * the container view. `borderWidth` arrives as a CSS px-string (e.g. `'1px'`) from the resolver; + * `pxToNumber` bridges it to the plain number RN's `ViewStyle` requires. */ +function toViewStyle(style: { + backgroundColor?: string; + borderColor?: string; + borderWidth?: string | number; +}): ViewStyle { + const { backgroundColor, borderColor, borderWidth } = style; + return { backgroundColor, borderColor, borderWidth: pxToNumber(borderWidth) }; +} + +/** `resolved.padding` is button.ts's `` `${spacing.sm} ${spacing.md}` `` — a fixed 2-value CSS + * shorthand (` `), not an arbitrary padding string. RN has no shorthand + * padding string support, so split it into the two `ViewStyle` fields it actually means. */ +function toPaddingStyle(padding: string): Pick { + const [vertical, horizontal] = padding.split(' '); + return { paddingVertical: pxToNumber(vertical), paddingHorizontal: pxToNumber(horizontal ?? vertical) }; +} + +export interface GdButtonProps { + variant?: ButtonVariantName; + disabled?: boolean; + isLoading?: boolean; + onPress?: (event: GestureResponderEvent) => void; + theme?: DesignCoreTheme; + children?: string; +} + +/** + * CTORNDSD-590 Button port (per the spike plan's Migration Example) — the platform's smoke test. + * Consumes gd-design-core's `resolveButtonVariantStyle`, the same token-resolved values the React + * web and Lit adapters use, mapping its state-keyed style objects onto `Pressable`'s `pressed` state + * function instead of a CSS pseudo-class — exactly the state mechanism the resolver was designed to + * be state-shape-neutral about. Falls back to the resolver's own hardcoded defaults when no theme is + * supplied, so this renders standalone without gd-design-library's real theme wired in yet. + */ +export function GdButton({ variant = 'primary', disabled, isLoading, onPress, theme = {}, children }: GdButtonProps) { + const resolved = resolveButtonVariantStyle(theme, variant); + const borderRadius = pxToNumber(resolveButtonRadius(theme)); + const isDisabled = disabled || isLoading; + const [isFocused, setIsFocused] = useState(false); + + return ( + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + disabled={isDisabled} + style={({ pressed }): ViewStyle[] => [ + { + ...toPaddingStyle(resolved.padding), + borderRadius, + alignItems: 'center', + ...toViewStyle(resolved.container), + ...(pressed && !isDisabled ? toViewStyle(resolved.containerActive) : null), + ...(isDisabled ? toViewStyle(resolved.containerDisabled) : null), + }, + // react-native-web renders this as a real `