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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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/**',
],
},
},
},
Expand Down
21 changes: 20 additions & 1 deletion libs/design-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
62 changes: 62 additions & 0 deletions libs/design-core/src/tokenResolvers/checkbox.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
78 changes: 69 additions & 9 deletions libs/design-core/src/tokenResolvers/checkbox.ts
Original file line number Diff line number Diff line change
@@ -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<CheckboxSizeName, { box: number; icon: number }> = {
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 && <span data-testid="...">{children}</span>}` 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'),
};
}
18 changes: 15 additions & 3 deletions libs/design-core/src/tokenResolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
114 changes: 114 additions & 0 deletions libs/design-core/src/tokenResolvers/input.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading