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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion packages/doc/generated/components/button.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ nav_order: 1

# Button

`variant="ghost"` is the lowest emphasis (tertiary), below `solid` and the default `soft`: no fill at rest, `text-primary` text and a `bg-subtle` wash when pressed. It keeps each size's height and padding, so its hit area matches `solid` and `soft`. Use it for an action inside content, where a fill would compete with that content. It is never a screen's main action or its only one. `accent` does not paint ghost: the prop is accepted and has no effect, as on `IconButton`.

## API

### Button Props

| Prop | Required | Type | Notes |
| --- | --- | --- | --- |
| `variant` | no | `'solid' | 'soft'` | style axis |
| `variant` | no | `'solid' | 'soft' | 'ghost'` | style axis |
| `size` | no | `'sm' | 'lg'` | style axis |
| `fill` | no | `'natural' | 'even' | 'hug'` | style axis |
| `accent` | no | `'neutral' | 'lilac' | 'orange'` | theme scope |
Expand Down
1 change: 1 addition & 0 deletions packages/doc/pipeline/component-api-ir.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const COMPONENT_API_DOCS = [
file: 'packages/rn/generated/components/button.ts',
type: 'ButtonProps',
relatedPrefix: 'Button',
lead: '`variant="ghost"` is the lowest emphasis (tertiary), below `solid` and the default `soft`: no fill at rest, `text-primary` text and a `bg-subtle` wash when pressed. It keeps each size\'s height and padding, so its hit area matches `solid` and `soft`. Use it for an action inside content, where a fill would compete with that content. It is never a screen\'s main action or its only one. `accent` does not paint ghost: the prop is accepted and has no effect, as on `IconButton`.',
},
{
source: 'alert',
Expand Down
8 changes: 7 additions & 1 deletion packages/doc/pipeline/docs-drift.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const PAGE_CONTRACT = {
kind: 'api', source: 'button', title: 'Button', nav: 1,
cells: [
'### Button Props',
"| `variant` | no | `'solid' | 'soft'` | style axis |",
"| `variant` | no | `'solid' | 'soft' | 'ghost'` | style axis |",
"| `size` | no | `'sm' | 'lg'` | style axis |",
"| `fill` | no | `'natural' | 'even' | 'hug'` | style axis |",
"| `accent` | no | `'neutral' | 'lilac' | 'orange'` | theme scope |",
Expand All @@ -76,6 +76,12 @@ const PAGE_CONTRACT = {
'### Button Icon Props',
'| `name` | yes | `IconName` | scalar icon name |',
'> `children` is not accepted (`children?: never`).',
// the lead's four ghost points (#233 · the admitted docs contract): tertiary ·
// inside content · never the main/only action · accent does not paint it.
'is the lowest emphasis (tertiary)',
'Use it for an action inside content, where a fill would compete with that content.',
"It is never a screen's main action or its only one.",
'`accent` does not paint ghost',
],
excludes: ['## Example', '## Anatomy', '## Base', '## Token map'],
},
Expand Down
17 changes: 15 additions & 2 deletions packages/expo-demo/src/screens/Bitcoin.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import * as React from 'react';

import { Button, Text, View } from '@ds';
import { Button, ButtonIcon, ButtonText, Text, View } from '@ds';

const BTC = '₿ 0.0413';
const EUR = '€ 2 634';

export function Bitcoin() {
// The other-unit amount under the big one is a ghost swap (#233): an action inside
// content, so no fill competes with the amount. The screen owns the unit state.
const [inEuro, setInEuro] = React.useState(false);

return (
<View direction="column" align="stretch" justify="start" gap="lg" paddingX="lg" paddingY="md" fill="grow">
<View aspectRatio="card" radius="lg" />
<Text size="3xl" align="center">₿ 0.0413</Text>
<View direction="column" align="center" gap="xs">
<Text size="3xl" align="center">{inEuro ? EUR : BTC}</Text>
<Button size="sm" variant="ghost" onPress={() => setInEuro((value) => !value)}>
<ButtonIcon name="transfer-vertical" />
<ButtonText>{inEuro ? BTC : EUR}</ButtonText>
</Button>
</View>
<View direction="row" align="center" gap="sm" distribute="even">
<Button size="lg" variant="soft">Receive</Button>
<Button size="lg" variant="solid" accent="orange">Send</Button>
Expand Down
21 changes: 20 additions & 1 deletion packages/playground/pages/component/button.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,37 @@
</template>
</nuri-demo>

<!-- variant · solid · soft · a per-instance accent override · a disabled solid. -->
<!-- variant · solid · soft · ghost · a per-instance accent override · a disabled solid. -->
<nuri-demo layout="board" controls="device,theme,accent" label="variant">
<template>
<nuri-view direction="column" align="center" justify="center" gap="md" fill>
<nuri-button variant="solid">Solid</nuri-button>
<nuri-button variant="soft">Soft</nuri-button>
<nuri-button variant="ghost">Ghost</nuri-button>
<nuri-button variant="solid" accent="orange">Solid accent</nuri-button>
<nuri-button variant="solid" disabled>Disabled</nuri-button>
</nuri-view>
</template>
</nuri-demo>

<!-- ghost · the lowest emphasis (tertiary · #233): no fill, text-primary, bg-subtle when
pressed. For an action inside content, where a fill would compete with it; never a
screen's main or only action. sm 36 / lg 60 like soft and solid; accent paints
nothing (the orange row matches the plain one). -->
<nuri-demo layout="board" controls="device,theme,accent" label="ghost">
<template>
<nuri-view direction="column" align="center" justify="center" gap="md" fill>
<nuri-button variant="ghost" size="sm">
<nuri-button-icon name="transfer-vertical"></nuri-button-icon>
<nuri-button-text>₿ 27 435</nuri-button-text>
</nuri-button>
<nuri-button variant="ghost" size="lg">Ghost</nuri-button>
<nuri-button variant="ghost" size="lg" accent="orange">Ghost accent</nuri-button>
<nuri-button variant="ghost" size="lg" disabled>Disabled</nuri-button>
</nuri-view>
</template>
</nuri-demo>

<!-- with icons · ordered ButtonText + ButtonIcon lockups: trailing icon · mid icon · leading icon. -->
<nuri-demo layout="board" controls="device,theme,accent" label="with icons">
<template>
Expand Down
1 change: 1 addition & 0 deletions packages/prototype/generated/descriptors/button.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const buttonDescriptor = {
variant: {
solid: { root: { palette: { variant: 'solid' } } },
soft: { root: { palette: { variant: 'soft' } } },
ghost: { root: { palette: { variant: 'ghost' } } },
},
size: {
sm: {
Expand Down
12 changes: 12 additions & 0 deletions packages/rn/__tests__/geometry-bake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ describe('geometry bake · guard 1 — ORACLE EQUIVALENCE (baked ≡ runtime res
}
});

describe('geometry bake · guard 1 COVERS — the oracle product reaches every Button variant', () => {
test('button · ghost × sm|lg × fill is in the cross-product (#233 · the cells #154 dropped)', () => {
const product = selections(buttonDescriptor as Descriptor<Axes>);
expect(product).toHaveLength(3 * 2 * 3); // variant (solid·soft·ghost) × size (sm·lg) × fill (natural·even·hug)
for (const size of ['sm', 'lg']) {
for (const fill of ['natural', 'even', 'hug']) {
expect(product).toContainEqual({ variant: 'ghost', size, fill });
}
}
});
});

describe('geometry bake · guard 1 BINDS — mutation proof (a stale bake WOULD fail)', () => {
test('mutating one descriptor box cell breaks the baked ≡ runtime equality', () => {
const theme = buildNuriTheme('lilac', 'light');
Expand Down
38 changes: 38 additions & 0 deletions packages/rn/__tests__/render-smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,44 @@ describe('render-smoke — the ergonomic components mount headless', () => {
expect(tr.toJSON()).toMatchSnapshot();
});

test('Button — ghost (#233): no fill at rest, bg-subtle pressed, text-primary label, sm 36 / lg 60, accent paints nothing', () => {
const cell = (mode: 'light' | 'dark', props: { size: 'sm' | 'lg'; accent?: 'lilac' | 'orange' | 'neutral'; disabled?: boolean }) => {
const tr = render(
<NuriThemeProvider mode={mode}>
<Button variant="ghost" onPress={() => undefined} {...props}>
₿ 27 435
</Button>
</NuriThemeProvider>,
);
const [button] = pressableActions(tr);
return {
role: button.props.accessibilityRole,
rest: flatStyleForTest(button.props.style({ pressed: false })),
pressed: flatStyleForTest(button.props.style({ pressed: true })),
label: flatStyleForTest(tr.root.findByType(Text).props.style).color,
};
};
for (const mode of ['light', 'dark'] as const) {
const ghost = buildNuriTheme('lilac', mode).surface.ghost;
const sm = cell(mode, { size: 'sm' });
expect(sm.role).toBe('button');
expect(sm.rest.backgroundColor).toBe('transparent');
expect(sm.pressed.backgroundColor).toBe(ghost.pressedBg);
expect(sm.pressed.transform).toEqual([{ scale: buildNuriTheme('lilac', mode).interaction.pressScale }]);
expect(sm.label).toBe(ghost.fg);
expect(sm.rest.minHeight).toBe(size.md); // 36
expect(cell(mode, { size: 'lg' }).rest.minHeight).toBe(size.xl); // 60
// accent does not paint ghost (decision 39): the orange/neutral cells equal the lilac one.
for (const accent of ['orange', 'neutral'] as const) {
expect(cell(mode, { size: 'sm', accent })).toEqual(sm);
}
const disabled = cell(mode, { size: 'sm', disabled: true });
expect(disabled.rest.opacity).toBe(buildNuriTheme('lilac', mode).interaction.disabledOpacity);
expect(disabled.pressed.backgroundColor).toBe('transparent');
}
expect([size.md, size.xl]).toEqual([36, 60]);
});

test('Button — label text is a single-line shrinkable control label', () => {
const tr = render(
<NuriThemeProvider>
Expand Down
23 changes: 23 additions & 0 deletions packages/rn/__tests__/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ describe('Button — the richest descriptor (every namespace + interactive · vi
test('variant → backgroundColor (palette · spot-assert)', () => {
expect(rootCell('solid', 'lg').style.backgroundColor).toBe(acc('lilac', 'solid', 'light'));
expect(rootCell('soft', 'lg').style.backgroundColor).toBe(chrome.light.bgStrong);
expect(rootCell('ghost', 'lg').style.backgroundColor).toBe('transparent');
});

test('size → box geometry patches (sm/lg · decision 41)', () => {
Expand All @@ -177,6 +178,7 @@ describe('Button — the richest descriptor (every namespace + interactive · vi
test('interactive transients — pressed colour per variant + scale + disabled opacity', () => {
expect(rootCell('solid', 'lg', { pressed: true }).style.backgroundColor).toBe(acc('lilac', 'solidPressed', 'light'));
expect(rootCell('soft', 'lg', { pressed: true }).style.backgroundColor).toBe(chrome.light.bgPressed);
expect(rootCell('ghost', 'lg', { pressed: true }).style.backgroundColor).toBe(chrome.light.bgSubtle);
expect(rootCell('solid', 'lg', { pressed: true }).style.transform).toEqual([{ scale: interactionTokens.pressScale }]);
expect(rootCell('solid', 'lg', { disabled: true }).style.opacity).toBe(interactionTokens.disabledOpacity);
});
Expand All @@ -197,12 +199,33 @@ describe('Button — the richest descriptor (every namespace + interactive · vi
// the root PROVIDES the surface fg per variant (scope-published · not a style patch)
expect(rootCell('solid', 'lg').node.fg).toBe(acc('lilac', 'onSolid', 'light'));
expect(rootCell('soft', 'lg').node.fg).toBe(chrome.light.textPrimary);
expect(rootCell('ghost', 'lg').node.fg).toBe(chrome.light.textPrimary);
// the label part carries ONLY typography — no colour patch, no own fg.
const label = flattenPart(buttonDescriptor, theme, 'label', { size: 'lg' }, {});
expect(label.style).toEqual({});
expect(label.node.fg).toBeUndefined();
});

test('ghost (#233) — shares soft\'s geometry per size; accent does not paint it (decision 39)', () => {
const geometry = (style: object) => ({ ...style, backgroundColor: undefined });
for (const s of ['sm', 'lg']) {
expect(geometry(rootCell('ghost', s).style)).toEqual(geometry(rootCell('soft', s).style));
expect(rootCell('ghost', s, { disabled: true }).style.opacity).toBe(interactionTokens.disabledOpacity);
}
// the ghost cells read only chrome (transparent · bg-subtle · text-primary) — identical under every accent.
for (const mode of ['light', 'dark'] as const) {
const cell = (accent: 'lilac' | 'orange' | 'neutral', state = {}) =>
flattenPart(buttonDescriptor, buildNuriTheme(accent, mode), 'root', { variant: 'ghost', size: 'sm' }, state);
for (const accent of ['orange', 'neutral'] as const) {
expect(cell(accent).style).toEqual(cell('lilac').style);
expect(cell(accent, { pressed: true }).style).toEqual(cell('lilac', { pressed: true }).style);
expect(cell(accent).node.fg).toBe(cell('lilac').node.fg);
}
expect(cell('orange', { pressed: true }).style.backgroundColor).toBe(chrome[mode].bgSubtle);
expect(cell('orange').node.fg).toBe(chrome[mode].textPrimary);
}
});

test('label type tracks size — orthogonal {size, emphasis} (decision 55 · de-fused 77)', () => {
const type = (s: string) => flattenPart(buttonDescriptor, theme, 'label', { size: s }, {}).node.type;
// The fused `smEm`/`mdEm` is gone — each value is the two orthogonal inputs.
Expand Down
2 changes: 1 addition & 1 deletion packages/rn/generated/components/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { Accent } from '../data/tokens';
import type { IconName } from '../data/icons';

export type ButtonProps = {
variant?: 'solid' | 'soft';
variant?: 'solid' | 'soft' | 'ghost';
size?: 'sm' | 'lg';
fill?: 'natural' | 'even' | 'hug';
accent?: Accent;
Expand Down
7 changes: 6 additions & 1 deletion packages/rn/type-tests/component-types.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import {
TopbarTitle,
TopbarTrailing,
} from '../index';
import type { IconAvatarProps, IconName, ListActionLeadingAvatarProps } from '../index';
import type { ButtonProps, IconAvatarProps, IconName, ListActionLeadingAvatarProps } from '../index';

// Exact type equality without casts (#231 · the forwarded-prop typing proof): true only
// when A and B are mutually identical — `unknown` or a widened union fails to compile.
Expand All @@ -90,6 +90,11 @@ export const nuriRootBadAccent = <NuriRoot accent="teal"><Screen /></NuriRoot>;
// ── Button — text sink · variant/size · NO icon (the soup is gone) ──
// the real surface compiles: variant union + children text.
export const buttonOk = <Button variant="solid" size="lg" onPress={() => undefined}>Send</Button>;
// #233 · ghost is back on the variant union (reverses #154's drop); the default stays soft.
export const buttonVariantUnion: Equal<Required<ButtonProps>['variant'], 'solid' | 'soft' | 'ghost'> = true;
export const buttonGhostOk = <Button variant="ghost" size="sm" onPress={() => undefined}>Swap</Button>;
// accent is accepted on ghost and paints nothing (decision 39 · documented, NOT type-excluded).
export const buttonGhostAccentOk = <Button variant="ghost" size="lg" accent="orange">Swap</Button>;
// bare children are the label sink.
export const buttonChildrenOk = <Button>Buy</Button>;
// composed ordered lockup: flat slot exports, icon slot uses `name`.
Expand Down
6 changes: 5 additions & 1 deletion packages/spec/components/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
import type { Descriptor } from './schema';

type ButtonAxes = {
variant: 'solid' | 'soft';
// `ghost` (#233 · admitted 2026-10-29 · reverses #154's non-use drop) = the lowest
// emphasis (tertiary): no fill, for an action inside content where a fill would
// compete with it; never a screen's main or only action. Accent does not paint it.
variant: 'solid' | 'soft' | 'ghost';
size: 'sm' | 'lg';
// How the button sits as a flex child of its parent row (the root's stack.fill).
// `natural` (default) composes nothing → the bare flex 0 1 auto: hugs its label,
Expand Down Expand Up @@ -58,6 +61,7 @@ export const buttonDescriptor: Descriptor<ButtonAxes> = {
variant: {
solid: { root: { palette: { variant: 'solid' } } },
soft: { root: { palette: { variant: 'soft' } } },
ghost: { root: { palette: { variant: 'ghost' } } },
},
size: {
sm: {
Expand Down
2 changes: 1 addition & 1 deletion scripts/docs-drift.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ const EXPECTED_DESCRIPTORS = {
interactive: [],
},
button: {
axes: { variant: ['solid', 'soft'], size: ['sm', 'lg'], fill: ['natural', 'even', 'hug'] },
axes: { variant: ['solid', 'soft', 'ghost'], size: ['sm', 'lg'], fill: ['natural', 'even', 'hug'] },
parts: ['label', 'icon'], // the anatomy's non-root parts
interactive: ['pressColor', 'pressScale', 'disabledOpacity'], // the collapsed root opt-in (§8 · no compound)
},
Expand Down
Loading