@delacour/react-native-ui 0.1.0 → 0.2.0-alpha.20261010061731

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +2 -2
  2. package/src/components/badge/AGENTS.md +5 -0
  3. package/src/components/badge/badge-close-button.tsx +2 -2
  4. package/src/components/badge/badge.context.tsx +2 -1
  5. package/src/components/badge/badge.tsx +7 -3
  6. package/src/components/badge/badge.variants.test.ts +26 -0
  7. package/src/components/badge/badge.variants.ts +21 -0
  8. package/src/components/button/AGENTS.md +6 -0
  9. package/src/components/button/button.tsx +5 -0
  10. package/src/components/button/button.variants.test.ts +36 -0
  11. package/src/components/button/button.variants.ts +16 -0
  12. package/src/components/card/AGENTS.md +4 -0
  13. package/src/components/card/card.tsx +9 -3
  14. package/src/components/input/AGENTS.md +5 -0
  15. package/src/components/input/input.variants.test.ts +32 -0
  16. package/src/components/input/input.variants.ts +13 -1
  17. package/src/components/surface/AGENTS.md +10 -0
  18. package/src/components/surface/surface.context.tsx +3 -1
  19. package/src/components/surface/surface.tsx +21 -4
  20. package/src/components/surface/surface.variants.test.ts +47 -0
  21. package/src/components/surface/surface.variants.ts +32 -0
  22. package/src/components/text/AGENTS.md +8 -2
  23. package/src/components/text/text.tsx +7 -0
  24. package/src/components/text/text.variants.test.ts +24 -1
  25. package/src/components/text/text.variants.ts +5 -0
  26. package/src/components/textarea/AGENTS.md +3 -0
  27. package/src/lib/cn.parity.test.ts +62 -0
  28. package/src/lib/cn.ts +12 -7
  29. package/src/styles/tokens.css +33 -0
  30. package/src/styles/tokens.test.ts +34 -0
  31. package/src/styles/tokens.ts +9 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-alpha.20261010061731",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -110,7 +110,7 @@
110
110
  "gen-exports": "bun scripts/gen-exports.ts"
111
111
  },
112
112
  "dependencies": {
113
- "clsx": "^2.1.1",
113
+ "cn": "^0.4.0",
114
114
  "tailwind-merge": "^3.6.0",
115
115
  "tailwind-variants": "^3.3.1"
116
116
  },
@@ -86,3 +86,8 @@ A compact label for status, category or count. Compound root plus `Badge.Label`,
86
86
  - **String children** are wrapped in a `Badge.Label` automatically, consecutive
87
87
  strings collapsing into one — the same rule, and the same reason, as
88
88
  [`Button`](../button/AGENTS.md).
89
+
90
+ - **`material="etched"` squares the capsule and tints.** The root and the close button take
91
+ `rounded-sm`; `soft` status fills become `bg-<status>/8` in light and `/16` in dark, written out
92
+ per colour because Tailwind's scanner cannot see a built class. The material rides the badge
93
+ context so the close button follows the root.
@@ -30,13 +30,13 @@ export function BadgeCloseButton({
30
30
  feedback = "fade",
31
31
  ...props
32
32
  }: BadgeCloseButtonProps): ReactElement {
33
- const { size, isDisabled } = useBadgePart("Badge.CloseButton");
33
+ const { size, isDisabled, material } = useBadgePart("Badge.CloseButton");
34
34
 
35
35
  return (
36
36
  <Pressable
37
37
  accessibilityLabel={accessibilityLabel}
38
38
  accessibilityRole="button"
39
- className={badgeVariants({ size }).closeButton({ className })}
39
+ className={badgeVariants({ material, size }).closeButton({ className })}
40
40
  disabled={isDisabled}
41
41
  feedback={feedback}
42
42
  {...props}
@@ -1,5 +1,5 @@
1
1
  import { createContext, type ReactElement, type ReactNode, use } from "react";
2
- import type { BadgeColor, BadgeSize, BadgeVariant } from "./badge.variants";
2
+ import type { BadgeColor, BadgeMaterial, BadgeSize, BadgeVariant } from "./badge.variants";
3
3
 
4
4
  export type BadgeContextValue = {
5
5
  /** Size of the badge. */
@@ -10,6 +10,7 @@ export type BadgeContextValue = {
10
10
  color: BadgeColor;
11
11
  /** Whether the badge is disabled. */
12
12
  isDisabled: boolean;
13
+ material: BadgeMaterial;
13
14
  };
14
15
 
15
16
  const BadgeContext = createContext<BadgeContextValue | null>(null);
@@ -7,6 +7,7 @@ import { type BadgeContextValue, BadgeProvider } from "./badge.context";
7
7
  import {
8
8
  BADGE_FOREGROUND_TOKEN,
9
9
  type BadgeColor,
10
+ type BadgeMaterial,
10
11
  type BadgeSize,
11
12
  type BadgeVariant,
12
13
  badgeVariants,
@@ -23,6 +24,8 @@ export type BadgeProps = Omit<PressableProps, "asChild" | "busy" | "children" |
23
24
  /** What the surface means. */
24
25
  color?: BadgeColor;
25
26
  size?: BadgeSize;
27
+ /** `flat` by default, a capsule. `etched` squares the corner and tints soft status fills as an alpha. */
28
+ material?: BadgeMaterial;
26
29
  isDisabled?: boolean;
27
30
  /** Composes a trailing dismiss control in. Its press never reaches `onPress`. */
28
31
  onClose?: () => void;
@@ -36,6 +39,7 @@ export type BadgeProps = Omit<PressableProps, "asChild" | "busy" | "children" |
36
39
  function BadgeRoot({
37
40
  variant = "solid",
38
41
  color = "default",
42
+ material = "flat",
39
43
  size = "md",
40
44
  isDisabled = false,
41
45
  onClose,
@@ -52,11 +56,11 @@ function BadgeRoot({
52
56
  ...props
53
57
  }: BadgeProps): ReactElement {
54
58
  const context = useMemo<BadgeContextValue>(
55
- () => ({ variant, color, size, isDisabled }),
56
- [variant, color, size, isDisabled]
59
+ () => ({ variant, color, size, isDisabled, material }),
60
+ [variant, color, size, isDisabled, material]
57
61
  );
58
62
 
59
- const slots = badgeVariants({ color, isDisabled, size, variant });
63
+ const slots = badgeVariants({ color, isDisabled, material, size, variant });
60
64
 
61
65
  // Icons composed into the badge adopt these unless told otherwise.
62
66
  const iconClassName = slots.icon();
@@ -267,3 +267,29 @@ describe("resolveBadgeInteractive", () => {
267
267
  expect(resolveBadgeInteractive({ onPress: noop, onLongPress: noop })).toBe(true);
268
268
  });
269
269
  });
270
+
271
+ describe("the etched badge material", () => {
272
+ test("flat keeps the capsule", () => {
273
+ expect(badgeVariants({}).root()).toContain("rounded-full");
274
+ });
275
+
276
+ test("etched squares the corner to the small step", () => {
277
+ const cls = badgeVariants({ material: "etched" }).root();
278
+ expect(cls).toContain("rounded-sm");
279
+ expect(cls).not.toContain("rounded-full");
280
+ expect(badgeVariants({ material: "etched" }).closeButton()).toContain("rounded-sm");
281
+ });
282
+
283
+ test("etched soft status fills are alpha tints of the status colour", () => {
284
+ for (const [color, fill] of [
285
+ ["success", "bg-success/8"],
286
+ ["warning", "bg-warning/8"],
287
+ ["destructive", "bg-destructive/8"],
288
+ ["info", "bg-info/8"],
289
+ ] as const) {
290
+ const cls = badgeVariants({ color, material: "etched", variant: "soft" }).root();
291
+ expect(cls).toContain(fill);
292
+ expect(cls).toContain(`dark:${fill.replace("/8", "/16")}`);
293
+ }
294
+ });
295
+ });
@@ -7,10 +7,18 @@ export const BADGE_VARIANTS = ["solid", "soft", "outline", "ghost"] as const;
7
7
  /** What the surface means. Orthogonal to {@link BADGE_VARIANTS}. */
8
8
  export const BADGE_COLORS = ["default", "primary", "success", "warning", "destructive", "info"] as const;
9
9
 
10
+ /**
11
+ * `flat` is the library default, a capsule. `etched` squares it to the small
12
+ * corner and tints the status fills as a low alpha of the status colour, so a
13
+ * badge sits quietly on any surface it lands on.
14
+ */
15
+ export const BADGE_MATERIALS = ["flat", "etched"] as const;
16
+
10
17
  export const BADGE_SIZES = ["sm", "md", "lg"] as const;
11
18
 
12
19
  export type BadgeVariant = (typeof BADGE_VARIANTS)[number];
13
20
  export type BadgeColor = (typeof BADGE_COLORS)[number];
21
+ export type BadgeMaterial = (typeof BADGE_MATERIALS)[number];
14
22
  export type BadgeSize = (typeof BADGE_SIZES)[number];
15
23
 
16
24
  /**
@@ -148,6 +156,7 @@ export const badgeVariants = tv({
148
156
  icon: "size-icon-md",
149
157
  },
150
158
  },
159
+ material: { flat: {}, etched: {} },
151
160
  // The empty `false` branch is load-bearing typing, not a placeholder.
152
161
  // See the note in button.variants.ts.
153
162
  isDisabled: { true: { root: "opacity-50" }, false: {} },
@@ -204,6 +213,18 @@ export const badgeVariants = tv({
204
213
  { variant: "ghost", color: "warning", class: { label: "text-warning-soft-foreground" } },
205
214
  { variant: "ghost", color: "destructive", class: { label: "text-destructive-soft-foreground" } },
206
215
  { variant: "ghost", color: "info", class: { label: "text-info-soft-foreground" } },
216
+
217
+ // Written out per colour: Tailwind's scanner cannot see `bg-${color}/8`.
218
+ { material: "etched", class: { root: "rounded-sm", closeButton: "rounded-sm" } },
219
+ { material: "etched", variant: "soft", color: "success", class: { root: "bg-success/8 dark:bg-success/16" } },
220
+ { material: "etched", variant: "soft", color: "warning", class: { root: "bg-warning/8 dark:bg-warning/16" } },
221
+ {
222
+ material: "etched",
223
+ variant: "soft",
224
+ color: "destructive",
225
+ class: { root: "bg-destructive/8 dark:bg-destructive/16" },
226
+ },
227
+ { material: "etched", variant: "soft", color: "info", class: { root: "bg-info/8 dark:bg-info/16" } },
207
228
  ],
208
229
  defaultVariants: {
209
230
  variant: "solid",
@@ -249,3 +249,9 @@ for a chunk that says something rather than doing something. An
249
249
  than `primary` when nothing names a variant: a chunk that cannot be pressed
250
250
  should not wear the group's action paint. A group that *does* name a variant
251
251
  is followed, so an outline run reads as one piece.
252
+
253
+ - **`material="etched"` is opt-in and per fill.** `primary` takes `shadow-etched-primary` (a white
254
+ inset top edge at 16%); `secondary`, `tertiary` and `outline` take the light/dark edge
255
+ (`shadow-etched dark:shadow-etched-dark`); `ghost`, `destructive` and `destructive-soft` stay flat
256
+ — a ghost has no surface to lift and a signal colour does not take a second highlight. The press
257
+ scale is already 0.97. The tokens live in `tokens.css`.
@@ -7,6 +7,7 @@ import { type ButtonContextValue, ButtonProvider, useButtonGroupItemContext } fr
7
7
  import {
8
8
  BUTTON_FOREGROUND_TOKEN,
9
9
  type ButtonLayout,
10
+ type ButtonMaterial,
10
11
  type ButtonSize,
11
12
  type ButtonSpinnerPlacement,
12
13
  type ButtonVariant,
@@ -23,6 +24,8 @@ import { ButtonStartContent } from "./button-start-content";
23
24
 
24
25
  export type ButtonProps = Omit<PressableProps, "busy" | "children" | "disabled" | "pressedOpacity" | "pressedScale"> & {
25
26
  variant?: ButtonVariant;
27
+ /** `flat` by default; `etched` sets a highlight into the fill's edge. */
28
+ material?: ButtonMaterial;
26
29
  /** Height, label step, icon step, corner and padding on one axis. `icon-*` is a square. */
27
30
  size?: ButtonSize;
28
31
  isDisabled?: boolean;
@@ -37,6 +40,7 @@ export type ButtonProps = Omit<PressableProps, "busy" | "children" | "disabled"
37
40
 
38
41
  function ButtonRoot({
39
42
  variant,
43
+ material = "flat",
40
44
  size,
41
45
  isDisabled,
42
46
  isLoading = false,
@@ -75,6 +79,7 @@ function ButtonRoot({
75
79
  isLoading,
76
80
  isSeamed: item?.isSeamed ?? false,
77
81
  isStretched: item?.isStretched ?? false,
82
+ material,
78
83
  orientation: item?.orientation ?? "horizontal",
79
84
  size: resolvedSize,
80
85
  variant: resolvedVariant,
@@ -8,6 +8,7 @@ import {
8
8
  BUTTON_GROUP_SEPARATOR_ORIENTATION,
9
9
  BUTTON_ICON_SIZES,
10
10
  BUTTON_LABEL_SIZES,
11
+ BUTTON_MATERIALS,
11
12
  BUTTON_SIZES,
12
13
  BUTTON_SPINNER_PLACEMENTS,
13
14
  BUTTON_VARIANTS,
@@ -812,6 +813,41 @@ describe("resolveGroupedButtonSize", () => {
812
813
  });
813
814
  });
814
815
 
816
+ describe("the etched button material", () => {
817
+ test("flat is the default and adds no shadow", () => {
818
+ for (const variant of BUTTON_VARIANTS) {
819
+ expect(buttonVariants({ variant }).root()).toBe(buttonVariants({ variant, material: "flat" }).root());
820
+ expect(buttonVariants({ variant }).root()).not.toMatch(/shadow/);
821
+ }
822
+ });
823
+
824
+ test("etched primary carries the white inset highlight", () => {
825
+ const cls = buttonVariants({ material: "etched", variant: "primary" }).root();
826
+ expect(cls).toContain("shadow-etched-primary");
827
+ expect(cls).not.toContain("shadow-etched ");
828
+ });
829
+
830
+ test("etched filled and outlined buttons carry the light and dark edge", () => {
831
+ for (const variant of ["secondary", "tertiary", "outline"] as const) {
832
+ const cls = buttonVariants({ material: "etched", variant }).root();
833
+ expect(cls).toContain("shadow-etched");
834
+ expect(cls).toContain("dark:shadow-etched-dark");
835
+ }
836
+ });
837
+
838
+ // A ghost has no surface to lift, and a destructive fill is a signal colour
839
+ // whose highlight would read as a second state.
840
+ test("ghost and destructive variants stay flat", () => {
841
+ for (const variant of ["ghost", "destructive", "destructive-soft"] as const) {
842
+ expect(buttonVariants({ material: "etched", variant }).root()).not.toMatch(/shadow/);
843
+ }
844
+ });
845
+
846
+ test("lists every material", () => {
847
+ expect(BUTTON_MATERIALS).toEqual(["flat", "etched"]);
848
+ });
849
+ });
850
+
815
851
  describe("a detached group", () => {
816
852
  test("places every member on its own", () => {
817
853
  // `only` is the lone member's position and draws the lone button's corner,
@@ -13,6 +13,14 @@ export const BUTTON_VARIANTS = [
13
13
  ] as const;
14
14
 
15
15
  /** Sizes that hold a label. Horizontal padding; the width comes from the content. */
16
+ /**
17
+ * `flat` is the library default. `etched` sets the highlight into the fill's
18
+ * edge — a white inset top edge on `primary`, the light/dark edge on the quiet
19
+ * fills. Ghost and destructive buttons stay flat: a ghost has no surface to
20
+ * lift, and a signal colour does not take a second highlight.
21
+ */
22
+ export const BUTTON_MATERIALS = ["flat", "etched"] as const;
23
+
16
24
  export const BUTTON_LABEL_SIZES = ["sm", "md", "lg"] as const;
17
25
 
18
26
  /** Sizes with a square footprint, for a button whose only content is an icon. */
@@ -33,6 +41,7 @@ export const BUTTON_GROUP_ORIENTATIONS = ["horizontal", "vertical"] as const;
33
41
 
34
42
  export const BUTTON_GROUP_POSITIONS = ["first", "middle", "last", "only"] as const;
35
43
 
44
+ export type ButtonMaterial = (typeof BUTTON_MATERIALS)[number];
36
45
  export type ButtonVariant = (typeof BUTTON_VARIANTS)[number];
37
46
  export type ButtonLabelSize = (typeof BUTTON_LABEL_SIZES)[number];
38
47
  export type ButtonIconSize = (typeof BUTTON_ICON_SIZES)[number];
@@ -217,6 +226,7 @@ export const buttonVariants = tv({
217
226
  // orientation, so it lands here rather than in six compound cells that
218
227
  // would all say the same word.
219
228
  groupPosition: { none: {}, first: {}, middle: { root: "rounded-none" }, last: {}, only: {} },
229
+ material: { flat: {}, etched: {} },
220
230
  // The empty `false` branches are load-bearing typing, not placeholders.
221
231
  // `tv` derives the prop type from the declared keys, so a map with only
222
232
  // `true` types the prop as `true` rather than `boolean` and rejects
@@ -229,6 +239,12 @@ export const buttonVariants = tv({
229
239
  isDimmedWhileLoading: { true: {}, false: {} },
230
240
  },
231
241
  compoundVariants: [
242
+ { material: "etched", variant: "primary", class: { root: "shadow-etched-primary" } },
243
+ {
244
+ material: "etched",
245
+ variant: ["secondary", "tertiary", "outline"],
246
+ class: { root: "shadow-etched dark:shadow-etched-dark" },
247
+ },
232
248
  // Loading is not a disabled state. The button keeps full contrast — the
233
249
  // spinner already says the press landed — unless the caller opts in.
234
250
  { isLoading: true, isDimmedWhileLoading: true, class: { root: "opacity-50" } },
@@ -86,3 +86,7 @@ A content surface with a header, a body and a footer, built on
86
86
  that knows what the press means. The playground's plan picker is the example.
87
87
  - **No text treatment on the root, header, content or footer** (rule 1). The
88
88
  tests assert it across every combination.
89
+
90
+ - **`material` passes straight to `Surface`.** `etched` lifts the card with the edge highlight;
91
+ `tray` frames panels. A card inside a tray takes the xl corner and the card fill. Pair `etched`
92
+ with `className="rounded-2xl"` for the 18-point corner; the radius is not part of the material.
@@ -15,19 +15,25 @@ export type CardProps = Omit<SurfaceProps, "padding"> & {
15
15
  size?: CardSize;
16
16
  };
17
17
 
18
- function CardRoot({ variant, size = "md", className, children, ...props }: CardProps): ReactElement {
18
+ function CardRoot({ variant, material = "flat", size = "md", className, children, ...props }: CardProps): ReactElement {
19
19
  const parentPlane = useSurfaceContext()?.plane ?? null;
20
20
 
21
21
  // Resolved here as well as inside `Surface`, because the parts need the plane
22
22
  // the card landed on — the title's colour and a band footer's fill key off it.
23
23
  const resolved = resolveSurfaceVariant({ parentPlane, variant });
24
- const plane = resolveSurfacePlane({ parentPlane, variant: resolved });
24
+ const plane = resolveSurfacePlane({ material, parentPlane, variant: resolved });
25
25
 
26
26
  const context = useMemo<CardContextValue>(() => ({ plane, size, variant: resolved }), [plane, size, resolved]);
27
27
 
28
28
  return (
29
29
  <CardProvider value={context}>
30
- <Surface className={cardVariants({ size }).root({ className })} padding="none" variant={resolved} {...props}>
30
+ <Surface
31
+ className={cardVariants({ size }).root({ className })}
32
+ material={material}
33
+ padding="none"
34
+ variant={resolved}
35
+ {...props}
36
+ >
31
37
  {children}
32
38
  </Surface>
33
39
  </CardProvider>
@@ -124,3 +124,8 @@ reads as one control rather than two boxes touching.
124
124
  wins. Disabled is the outermost rung of an already-three-deep ladder
125
125
  (`Input.Group` → own prop → `Field` → `Button.Group`), so a single field in a
126
126
  run can still be the only one disabled.
127
+
128
+ - **`variant="etched"` is the opt-in devl field.** `border-input bg-card` with the light/dark edge
129
+ highlight; focus swaps it for `border-ring` and a 3pt neutral halo (`shadow-focus`, `-dark`).
130
+ The halo is a plain alpha, not `color-mix` on the ring: `color-mix` inside a `box-shadow` does not
131
+ survive Uniwind's compile and draws nothing. Invalid keeps the highlight and the destructive border.
@@ -367,3 +367,35 @@ describe("a joined field's corner", () => {
367
367
  }
368
368
  });
369
369
  });
370
+
371
+ describe("the etched input variant", () => {
372
+ test("is listed after the two flat variants", () => {
373
+ expect(INPUT_VARIANTS).toEqual(["primary", "secondary", "etched"]);
374
+ });
375
+
376
+ test("is a card fill with the input border and the light and dark edge", () => {
377
+ const cls = inputVariants({ variant: "etched" }).root();
378
+ expect(cls).toContain("bg-card");
379
+ expect(cls).toContain("border-input");
380
+ expect(cls).toContain("shadow-etched");
381
+ expect(cls).toContain("dark:shadow-etched-dark");
382
+ });
383
+
384
+ test("focus swaps the border to the ring and adds the 3px halo", () => {
385
+ const cls = inputVariants({ isFocused: true, variant: "etched" }).root();
386
+ expect(cls).toContain("border-ring");
387
+ expect(cls).toContain("shadow-focus");
388
+ expect(cls).toContain("dark:shadow-focus-dark");
389
+ expect(cls).not.toContain("shadow-etched");
390
+ });
391
+
392
+ test("invalid outranks focus", () => {
393
+ const cls = inputVariants({ isFocused: true, isInvalid: true, variant: "etched" }).root();
394
+ expect(cls).toContain("border-destructive");
395
+ expect(cls).not.toContain("shadow-focus");
396
+ });
397
+
398
+ test("the flat variants draw no shadow", () => {
399
+ expect(inputVariants({ isFocused: true, variant: "primary" }).root()).not.toMatch(/shadow/);
400
+ });
401
+ });
@@ -3,7 +3,7 @@ import { cn } from "../../lib/cn";
3
3
  import { tv } from "../../lib/tv";
4
4
  import type { ButtonGroupOrientation, ButtonGroupSlotPosition } from "../button/button.variants";
5
5
 
6
- export const INPUT_VARIANTS = ["primary", "secondary"] as const;
6
+ export const INPUT_VARIANTS = ["primary", "secondary", "etched"] as const;
7
7
 
8
8
  export const INPUT_SIZES = ["sm", "md", "lg"] as const;
9
9
 
@@ -79,6 +79,9 @@ export const inputVariants = tv({
79
79
  variant: {
80
80
  primary: { root: "border-input bg-card" },
81
81
  secondary: { root: "border-transparent bg-secondary" },
82
+ // The card fill with the highlight set into its edge. Focus swaps
83
+ // that for a ring border and a three-point halo — see the compound.
84
+ etched: { root: "border-input bg-card shadow-etched dark:shadow-etched-dark" },
82
85
  },
83
86
  size: {
84
87
  sm: {
@@ -145,6 +148,15 @@ export const inputVariants = tv({
145
148
  // field that went grey the moment it was tapped would drop the only
146
149
  // signal it has that its value is wrong, exactly while it is being fixed.
147
150
  { isFocused: true, isInvalid: true, class: { root: "border-destructive" } },
151
+ // An etched field trades its edge highlight for the halo while it holds
152
+ // focus. Invalid keeps the highlight and the destructive border instead,
153
+ // so a wrong value never glows in the ring's colour.
154
+ {
155
+ isFocused: true,
156
+ isInvalid: false,
157
+ variant: "etched",
158
+ class: { root: "border-ring shadow-focus dark:shadow-focus-dark" },
159
+ },
148
160
  // The corner a lone field draws: the generic ramp, deliberately not the
149
161
  // button's, because a field and the button beside it are meant to be
150
162
  // retunable apart. `sm` steps down a notch; the other two share `lg`.
@@ -70,3 +70,13 @@ components built on it (`Card`, `Alert`) can read the plane they sit on.
70
70
  - **No role.** A surface is layout, not a landmark; it adds nothing a screen
71
71
  reader could announce. `ViewProps` pass through, so a caller that does mean
72
72
  one — `accessibilityRole="summary"` — sets it.
73
+
74
+ - **`material` is what the surface is made of, separate from which plane it fills.** `flat` is the
75
+ default and draws nothing extra. `etched` adds `shadow-etched dark:shadow-etched-dark`: a one-pixel
76
+ highlight set into the edge (a darkening on the bottom in light, a lightening on the top in dark)
77
+ and a very soft drop. `tray` is the muted frame that holds panels — `bg-muted/70`, `p-1`,
78
+ `rounded-2xl`, whatever `padding` says. A tray paints its own fill, so it resets the plane to
79
+ `null` (`resolveSurfacePlane`) and a surface inside it resolves to the card fill, not the next rung
80
+ down; `SurfaceContext.material` is how that panel knows to take `rounded-xl`, one step under the
81
+ tray's corner. A single `shadow-*` utility sets one `box-shadow`, which is why light and dark are
82
+ two tokens joined by `dark:` and not one composed class. Opt-in: nothing in the default look uses it.
@@ -1,5 +1,5 @@
1
1
  import { createContext, type ReactElement, type ReactNode, use } from "react";
2
- import type { SurfacePlane, SurfaceVariant } from "./surface.variants";
2
+ import type { SurfaceMaterial, SurfacePlane, SurfaceVariant } from "./surface.variants";
3
3
 
4
4
  export type SurfaceContextValue = {
5
5
  /** The fill the enclosing surface resolved to — explicit, or stepped from its own parent. */
@@ -10,6 +10,8 @@ export type SurfaceContextValue = {
10
10
  * plane beneath it through — `null` when there is none.
11
11
  */
12
12
  plane: SurfacePlane | null;
13
+ /** What the surface is made of. A `tray` tells the panels inside it to take the inner corner. */
14
+ material: SurfaceMaterial;
13
15
  };
14
16
 
15
17
  const SurfaceContext = createContext<SurfaceContextValue | null>(null);
@@ -4,6 +4,7 @@ import { type SurfaceContextValue, SurfaceProvider, useSurfaceContext } from "./
4
4
  import {
5
5
  resolveSurfacePlane,
6
6
  resolveSurfaceVariant,
7
+ type SurfaceMaterial,
7
8
  type SurfacePadding,
8
9
  type SurfaceVariant,
9
10
  surfaceVariants,
@@ -17,6 +18,8 @@ export type SurfaceProps = ViewProps & {
17
18
  variant?: SurfaceVariant;
18
19
  /** Inner spacing. `none` also clips, for content bled to the corners. */
19
20
  padding?: SurfacePadding;
21
+ /** `flat` by default. `etched` sets a highlight into the edge; `tray` is the muted frame that holds panels. */
22
+ material?: SurfaceMaterial;
20
23
  className?: string;
21
24
  children?: ReactNode;
22
25
  };
@@ -50,18 +53,32 @@ export type SurfaceProps = ViewProps & {
50
53
  * <Image className="h-40 w-full" source={cover} />
51
54
  * </Surface>
52
55
  */
53
- export function Surface({ variant, padding = "md", className, children, ...props }: SurfaceProps): ReactElement {
56
+ export function Surface({
57
+ variant,
58
+ padding = "md",
59
+ material = "flat",
60
+ className,
61
+ children,
62
+ ...props
63
+ }: SurfaceProps): ReactElement {
54
64
  const parent = useSurfaceContext();
55
65
  const parentPlane = parent?.plane ?? null;
56
66
 
57
67
  const resolved = resolveSurfaceVariant({ parentPlane, variant });
58
- const plane = resolveSurfacePlane({ parentPlane, variant: resolved });
68
+ const plane = resolveSurfacePlane({ material, parentPlane, variant: resolved });
69
+ const inTray = parent?.material === "tray";
59
70
 
60
- const context = useMemo<SurfaceContextValue>(() => ({ plane, variant: resolved }), [plane, resolved]);
71
+ const context = useMemo<SurfaceContextValue>(
72
+ () => ({ material, plane, variant: resolved }),
73
+ [material, plane, resolved]
74
+ );
61
75
 
62
76
  return (
63
77
  <SurfaceProvider value={context}>
64
- <View className={surfaceVariants({ padding, variant: resolved }).root({ className })} {...props}>
78
+ <View
79
+ className={surfaceVariants({ inTray, material, padding, variant: resolved }).root({ className })}
80
+ {...props}
81
+ >
65
82
  {children}
66
83
  </View>
67
84
  </SurfaceProvider>
@@ -6,6 +6,7 @@ import {
6
6
  resolveSurfaceVariant,
7
7
  SURFACE_FILLED_VARIANTS,
8
8
  SURFACE_FOREGROUND_TOKENS,
9
+ SURFACE_MATERIALS,
9
10
  SURFACE_PADDINGS,
10
11
  SURFACE_VARIANTS,
11
12
  surfaceVariants,
@@ -178,3 +179,49 @@ describe("resolveSurfacePlane", () => {
178
179
  expect(resolveSurfaceVariant({ parentPlane: through })).toBe("secondary");
179
180
  });
180
181
  });
182
+
183
+ describe("surface materials", () => {
184
+ test("flat is the default and adds nothing", () => {
185
+ expect(surfaceVariants().root()).toBe(surfaceVariants({ material: "flat" }).root());
186
+ expect(surfaceVariants().root()).not.toMatch(/shadow/);
187
+ });
188
+
189
+ test("etched lifts the edge with the light and dark highlight", () => {
190
+ const cls = surfaceVariants({ material: "etched" }).root();
191
+ expect(cls).toContain("shadow-etched");
192
+ expect(cls).toContain("dark:shadow-etched-dark");
193
+ expect(cls).toContain("bg-card");
194
+ });
195
+
196
+ test("a tray is a muted, squarely padded 2xl frame", () => {
197
+ const cls = surfaceVariants({ material: "tray", padding: "md" }).root();
198
+ expect(cls).toContain("bg-muted/70");
199
+ expect(cls).toContain("rounded-2xl");
200
+ expect(cls).toContain("p-1");
201
+ expect(cls).not.toMatch(/\bp-4\b/);
202
+ expect(cls).not.toContain("bg-card");
203
+ });
204
+
205
+ test("a panel in a tray takes the xl corner", () => {
206
+ expect(surfaceVariants({ inTray: true }).root()).toContain("rounded-xl");
207
+ expect(surfaceVariants({ inTray: true }).root()).not.toContain("rounded-lg");
208
+ });
209
+
210
+ test("lists every material", () => {
211
+ expect(SURFACE_MATERIALS).toEqual(["flat", "etched", "tray"]);
212
+ });
213
+ });
214
+
215
+ describe("tray planes", () => {
216
+ // The tray paints its own muted fill, so what sits in it steps from nothing:
217
+ // a default card panel, not a secondary one on a muted ground.
218
+ test("a tray resets the plane so its panels resolve to the card fill", () => {
219
+ const through = resolveSurfacePlane({ parentPlane: "tertiary", variant: "default", material: "tray" });
220
+ expect(through).toBeNull();
221
+ expect(resolveSurfaceVariant({ parentPlane: through })).toBe("default");
222
+ });
223
+
224
+ test("other materials leave the plane alone", () => {
225
+ expect(resolveSurfacePlane({ parentPlane: null, variant: "secondary", material: "etched" })).toBe("secondary");
226
+ });
227
+ });
@@ -5,6 +5,18 @@ export const SURFACE_VARIANTS = ["default", "secondary", "tertiary", "transparen
5
5
 
6
6
  export const SURFACE_PADDINGS = ["none", "sm", "md", "lg"] as const;
7
7
 
8
+ /**
9
+ * What the surface is made of, orthogonal to which plane it fills.
10
+ *
11
+ * `flat` is the library default and draws nothing extra. `etched` sets a
12
+ * one-pixel highlight into the edge with a very soft drop — see the
13
+ * `--shadow-etched` tokens. `tray` is the muted frame that holds panels: a
14
+ * 2xl corner, 4pt of padding and a translucent muted fill, so the panels in it
15
+ * read as inset cards rather than as loose boxes on a page.
16
+ */
17
+ export const SURFACE_MATERIALS = ["flat", "etched", "tray"] as const;
18
+
19
+ export type SurfaceMaterial = (typeof SURFACE_MATERIALS)[number];
8
20
  export type SurfaceVariant = (typeof SURFACE_VARIANTS)[number];
9
21
  export type SurfacePadding = (typeof SURFACE_PADDINGS)[number];
10
22
 
@@ -88,10 +100,25 @@ export const surfaceVariants = tv({
88
100
  md: { root: "p-4" },
89
101
  lg: { root: "p-6" },
90
102
  },
103
+ material: {
104
+ flat: {},
105
+ etched: { root: "shadow-etched dark:shadow-etched-dark" },
106
+ tray: {},
107
+ },
108
+ // A panel sitting in a tray: one corner step under the tray's own, so
109
+ // the gap between the two curves stays even round the edge.
110
+ inTray: { true: {}, false: {} },
91
111
  },
112
+ compoundVariants: [
113
+ // Beats the padding axis, so a tray is `p-1` whatever `padding` says.
114
+ { material: "tray", class: { root: "rounded-2xl border-transparent bg-muted/70 p-1" } },
115
+ { inTray: true, class: { root: "rounded-xl" } },
116
+ ],
92
117
  defaultVariants: {
93
118
  variant: "default",
94
119
  padding: "md",
120
+ material: "flat",
121
+ inTray: false,
95
122
  },
96
123
  });
97
124
 
@@ -123,11 +150,16 @@ export function resolveSurfaceVariant({
123
150
  * surface, which paints none — the plane it sits on itself.
124
151
  */
125
152
  export function resolveSurfacePlane({
153
+ material = "flat",
126
154
  parentPlane,
127
155
  variant,
128
156
  }: {
157
+ material?: SurfaceMaterial;
129
158
  parentPlane: SurfacePlane | null;
130
159
  variant: SurfaceVariant;
131
160
  }): SurfacePlane | null {
161
+ // A tray paints its own muted fill, so what it holds steps from no plane at
162
+ // all and resolves to the card fill rather than the next rung down.
163
+ if (material === "tray") return null;
132
164
  return variant === "transparent" ? parentPlane : variant;
133
165
  }
@@ -1,9 +1,9 @@
1
1
  # Text
2
2
 
3
3
  The library's type scale, and the one component that reproduces React Native's
4
- own text inheritance through classNames. Compound root plus twelve presets:
4
+ own text inheritance through classNames. Compound root plus thirteen presets:
5
5
  `Display`, `Title`, `Header`, `Subheader`, `Paragraph`, `Label`, `Caption`,
6
- `Overline`, and the four inline ones — `Strong`, `Emphasis`, `Link`, `Code`.
6
+ `Overline`, `Kicker`, and the four inline ones — `Strong`, `Emphasis`, `Link`, `Code`.
7
7
 
8
8
  `import { Text } from "@delacour/react-native-ui/text";`
9
9
 
@@ -61,6 +61,12 @@ own text inheritance through classNames. Compound root plus twelve presets:
61
61
  so Tailwind v4's logical-property utilities resolve to a value RN rejects.
62
62
  `transform: "none"` emits `normal-case` rather than an empty string, so it can
63
63
  actually clear an `Overline`'s `uppercase`.
64
+ - **`Kicker` is the mono, 10-point, 0.2em-tracked label — an opt-in sibling of
65
+ `Overline`, not a replacement.** Its size and tracking are the `--text-kicker`
66
+ and `--tracking-kicker` tokens in `tokens.css`, registered with
67
+ `tailwind-merge` as a pair (`KICKER_TOKEN`) so `text-kicker` is read as a
68
+ size and never as a colour. `font-mono` is deliberate: it labels, it does not
69
+ read as a sentence. Nothing in the default look uses it.
64
70
  - **A nested `Text.Code` cannot be padded.** A nested `<Text>` is laid out by the
65
71
  platform's text engine — an `NSAttributedString` run on iOS, a `Span` on
66
72
  Android — and both ignore padding, margin and border radius on an inner
@@ -142,6 +142,12 @@ function TextOverline(props: TextPresetProps): ReactElement {
142
142
  }
143
143
  TextOverline.displayName = "DelacourUI.Text.Overline";
144
144
 
145
+ /** The tiny tracked mono label over a group, a screen or a field. */
146
+ function TextKicker(props: TextPresetProps): ReactElement {
147
+ return <TextRoot variant="kicker" {...props} />;
148
+ }
149
+ TextKicker.displayName = "DelacourUI.Text.Kicker";
150
+
145
151
  /**
146
152
  * Inline emphasis by weight.
147
153
  *
@@ -238,6 +244,7 @@ export const Text = Object.assign(TextRoot, {
238
244
  /** Supporting copy — 14pt on the muted token. */
239
245
  Caption: TextCaption,
240
246
  /** An eyebrow above a section — 12pt semibold, uppercase, muted. */
247
+ Kicker: TextKicker,
241
248
  Overline: TextOverline,
242
249
  /** Inline emphasis by weight. Inherits size and colour from the text around it. */
243
250
  Strong: TextStrong,
@@ -17,7 +17,7 @@ import {
17
17
  } from "./text.variants";
18
18
 
19
19
  /** Tailwind's type scale, smallest first — position, so a test can say "a step down". */
20
- const TYPE_SCALE = ["text-xs", "text-sm", "text-base", "text-lg", "text-xl", "text-2xl", "text-3xl"];
20
+ const TYPE_SCALE = ["text-kicker", "text-xs", "text-sm", "text-base", "text-lg", "text-xl", "text-2xl", "text-3xl"];
21
21
 
22
22
  /** The three alignment utilities, which share the `text-` prefix with sizes and colours. */
23
23
  const ALIGNMENTS = ["text-left", "text-center", "text-right"];
@@ -420,3 +420,26 @@ describe("TEXT_MAX_FONT_SIZE_MULTIPLIER", () => {
420
420
  expect(TEXT_MAX_FONT_SIZE_MULTIPLIER).toBeGreaterThan(1);
421
421
  });
422
422
  });
423
+
424
+ describe("the kicker variant", () => {
425
+ const cls = textVariants({ variant: "kicker" });
426
+
427
+ test("is a ten-point uppercase mono label, tracked wide", () => {
428
+ expect(cls).toContain("font-mono");
429
+ expect(cls).toContain("text-kicker");
430
+ expect(cls).toContain("uppercase");
431
+ expect(cls).toContain("tracking-kicker");
432
+ expect(cls).toContain("text-muted-foreground");
433
+ });
434
+
435
+ test("is listed, and is not an inline variant", () => {
436
+ expect(TEXT_VARIANTS).toContain("kicker");
437
+ expect(isInlineTextVariant("kicker")).toBe(false);
438
+ });
439
+
440
+ test("survives resolveTextClass without losing its size to the base class", () => {
441
+ const resolved = resolveTextClass({ variant: "kicker" });
442
+ expect(resolved).toContain("text-kicker");
443
+ expect(resolved).not.toContain("text-base");
444
+ });
445
+ });
@@ -18,6 +18,7 @@ export const TEXT_VARIANTS = [
18
18
  "label",
19
19
  "caption",
20
20
  "overline",
21
+ "kicker",
21
22
  "strong",
22
23
  "emphasis",
23
24
  "link",
@@ -136,6 +137,10 @@ export const textVariants = tv({
136
137
  label: "font-medium text-sm text-foreground",
137
138
  caption: "text-sm text-muted-foreground",
138
139
  overline: "font-semibold text-muted-foreground text-xs uppercase tracking-wide",
140
+ // The tiny tracked mono label over a group or a screen. `font-mono`
141
+ // rather than `font-sans`, which is the point of it: it reads as a
142
+ // measurement, not a sentence. Tokens, not literals — see tokens.css.
143
+ kicker: "font-mono text-kicker text-muted-foreground uppercase tracking-kicker",
139
144
  // The four below emit a delta and nothing else. See TEXT_INLINE_VARIANTS.
140
145
  strong: "font-semibold",
141
146
  emphasis: "italic",
@@ -84,3 +84,6 @@ A multiline text field, sized in rows. One component, no parts: it renders an
84
84
  either context and take its box or its corner, but a decorated or joined
85
85
  paragraph is not a control this library draws, and the height math assumes
86
86
  the box is the field's own.
87
+
88
+ - **Takes `Input`'s `etched` variant** — the box is `Input`'s, so the third `INPUT_VARIANTS` value
89
+ reaches the textarea unchanged.
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { extendTailwindMerge } from "tailwind-merge";
5
+ import { type CustomClassGroupId, TW_MERGE_CONFIG } from "../styles/tokens";
6
+ import { cn } from "./cn";
7
+
8
+ /**
9
+ * `cn` against the tailwind-merge it replaced, on this library's own classes.
10
+ *
11
+ * `cn` promises tailwind-merge's output for every input; this holds it to that
12
+ * on the strings that matter here — every class literal in a `*.variants.ts`,
13
+ * merged pairwise with a caller-style override, through the same
14
+ * `TW_MERGE_CONFIG`. tailwind-merge stays installed as `tailwind-variants`'
15
+ * peer, which is what makes the reference available.
16
+ */
17
+ const reference = extendTailwindMerge<CustomClassGroupId>(TW_MERGE_CONFIG);
18
+
19
+ function variantFiles(dir: string): string[] {
20
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
21
+ const path = join(dir, entry.name);
22
+ if (entry.isDirectory()) return variantFiles(path);
23
+ return entry.name.endsWith(".variants.ts") ? [path] : [];
24
+ });
25
+ }
26
+
27
+ const literals = [
28
+ ...new Set(
29
+ variantFiles(join(import.meta.dir, "../components")).flatMap((file) =>
30
+ [...readFileSync(file, "utf8").matchAll(/"([a-z0-9!:[\]/.\-_% ]+)"/g)]
31
+ .map((match) => match[1] ?? "")
32
+ .filter((value) => value.includes("-"))
33
+ )
34
+ ),
35
+ ];
36
+
37
+ const OVERRIDES = [
38
+ "p-4",
39
+ "h-12",
40
+ "rounded-none",
41
+ "bg-destructive",
42
+ "text-sm",
43
+ "text-primary",
44
+ "opacity-50",
45
+ "h-button-sm",
46
+ ];
47
+
48
+ describe("cn matches tailwind-merge on the library's classes", () => {
49
+ test("found a real corpus", () => {
50
+ expect(literals.length).toBeGreaterThan(100);
51
+ });
52
+
53
+ test("every literal alone", () => {
54
+ for (const value of literals) expect(cn(value)).toBe(reference(value));
55
+ });
56
+
57
+ test("every literal under a caller override", () => {
58
+ for (const value of literals) {
59
+ for (const override of OVERRIDES) expect(cn(value, override)).toBe(reference(value, override));
60
+ }
61
+ });
62
+ });
package/src/lib/cn.ts CHANGED
@@ -1,11 +1,16 @@
1
- import { type ClassValue, clsx } from "clsx";
2
- import { extendTailwindMerge } from "tailwind-merge";
3
- import { type CustomClassGroupId, TW_MERGE_CONFIG } from "../styles/tokens";
1
+ import type { ClassValue } from "cn";
2
+ import { createCn } from "cn/config";
3
+ import { TW_MERGE_CONFIG } from "../styles/tokens";
4
4
 
5
5
  /**
6
- * tailwind-merge, taught the semantic size tokens from `tokens.css`.
6
+ * Class joining and conflict resolution, taught the semantic size tokens from `tokens.css`.
7
7
  *
8
- * Registering them is load-bearing rather than tidiness. tailwind-merge only
8
+ * Built on `cn` (shadcn-ui/cn), which replaces `clsx` + `tailwind-merge` with one
9
+ * engine: the same join semantics, the same merge output, and much cheaper on the
10
+ * repeated calls a render loop makes. `createCn` takes tailwind-merge's own
11
+ * `{ extend }` shape, so `TW_MERGE_CONFIG` is passed unchanged.
12
+ *
13
+ * Registering the tokens is load-bearing rather than tidiness. The merger only
9
14
  * treats two classes as conflicting when it recognises both as members of the
10
15
  * same group, and `button-md` is not a value it knows. Left unregistered,
11
16
  * `cn("h-button-md", "h-12")` returns *both* classes: they each resolve to a
@@ -15,7 +20,7 @@ import { type CustomClassGroupId, TW_MERGE_CONFIG } from "../styles/tokens";
15
20
  *
16
21
  * `tv()` needs the same treatment for its own merger — see `lib/tv.ts`.
17
22
  */
18
- const twMerge = extendTailwindMerge<CustomClassGroupId>(TW_MERGE_CONFIG);
23
+ const merge = createCn(TW_MERGE_CONFIG);
19
24
 
20
25
  /**
21
26
  * Merges class names and resolves Tailwind conflicts so the last utility wins.
@@ -25,5 +30,5 @@ const twMerge = extendTailwindMerge<CustomClassGroupId>(TW_MERGE_CONFIG);
25
30
  * `className` through this before handing it to a component.
26
31
  */
27
32
  export function cn(...inputs: ClassValue[]): string {
28
- return twMerge(clsx(inputs));
33
+ return merge(...inputs);
29
34
  }
@@ -107,6 +107,39 @@
107
107
  --spacing-chart-md: 220px;
108
108
  --spacing-chart-lg: 280px;
109
109
 
110
+ /*
111
+ * The kicker: the tiny uppercase mono label that sits over a group, a
112
+ * screen or a field. Ten points at 0.2em tracking, named as a pair so
113
+ * `Text variant="kicker"` and a hand-written `text-kicker tracking-kicker`
114
+ * agree. Nothing reads it unless asked, so the default look is unchanged.
115
+ */
116
+ --text-kicker: 10px;
117
+ --tracking-kicker: 0.2em;
118
+
119
+ /*
120
+ * The etched material: a one-pixel highlight set into a surface's edge plus
121
+ * a very soft drop. Light puts the highlight on the bottom edge as a
122
+ * darkening, dark puts it on the top edge as a lightening, so a card reads
123
+ * as lit from above in both. They are two tokens because one `shadow-*`
124
+ * utility sets one `box-shadow` and a scheme flip needs `dark:`.
125
+ *
126
+ * `etched-primary` is the primary button's own: a white inset top edge at
127
+ * 16% that is the same in both schemes, because the fill under it is the
128
+ * dark one in light and invisible against the pale one in dark.
129
+ *
130
+ * `focus` is the three-point halo an etched field draws round its ring
131
+ * border. A neutral alpha rather than the ring colour: `color-mix` inside a
132
+ * `box-shadow` does not survive Uniwind's compile and draws nothing, and
133
+ * the ring is a neutral in every base ramp anyway.
134
+ *
135
+ * Opt-in only: no default component reads any of these.
136
+ */
137
+ --shadow-etched: inset 0 -1px 0 0 rgb(0 0 0 / 4%), 0 1px 2px 0 rgb(0 0 0 / 5%);
138
+ --shadow-etched-dark: inset 0 1px 0 0 rgb(255 255 255 / 6%), 0 1px 2px 0 rgb(0 0 0 / 30%);
139
+ --shadow-etched-primary: inset 0 1px 0 0 rgb(255 255 255 / 16%), 0 1px 2px 0 rgb(0 0 0 / 5%);
140
+ --shadow-focus: 0 0 0 3px rgb(0 0 0 / 10%);
141
+ --shadow-focus-dark: 0 0 0 3px rgb(255 255 255 / 14%);
142
+
110
143
  /*
111
144
  * A floating action button's footprint — the circle's edge, and the
112
145
  * height of an extended one. A scale of its own rather than
@@ -6,11 +6,14 @@ import {
6
6
  BUTTON_SIZE_TOKENS,
7
7
  BUTTON_TEXT_TOKENS,
8
8
  CHART_SIZE_TOKENS,
9
+ ETCHED_SHADOW_TOKENS,
9
10
  FAB_SIZE_TOKENS,
10
11
  ICON_SIZE_TOKENS,
11
12
  INPUT_SIZE_TOKENS,
12
13
  INPUT_TEXT_TOKENS,
14
+ KICKER_TOKEN,
13
15
  SCREEN_SIZE_TOKENS,
16
+ TW_MERGE_CONFIG,
14
17
  } from "./tokens";
15
18
 
16
19
  const CSS = readFileSync(join(import.meta.dirname, "tokens.css"), "utf-8");
@@ -182,6 +185,37 @@ describe("the input scale", () => {
182
185
  });
183
186
  });
184
187
 
188
+ describe("the etched material", () => {
189
+ test("declares every shadow the registry names, and no other", () => {
190
+ const declared = [...CSS.matchAll(/--shadow-([\w-]+):/g)].map((match) => match[1]).sort();
191
+ expect(declared).toEqual([...ETCHED_SHADOW_TOKENS].sort());
192
+ });
193
+
194
+ // An inset layer is what makes it a highlight rather than a drop shadow, and
195
+ // the soft outer layer is what lifts the edge off the page.
196
+ test("light and dark edges sit on opposite sides of the surface", () => {
197
+ expect(CSS).toMatch(/--shadow-etched:\s*inset 0 -1px/);
198
+ expect(CSS).toMatch(/--shadow-etched-dark:\s*inset 0 1px/);
199
+ expect(CSS).toMatch(/--shadow-etched-primary:\s*inset 0 1px 0 0 rgb\(255 255 255 \/ 16%\)/);
200
+ });
201
+
202
+ test("registers its shadows so a caller's shadow-* can replace them", () => {
203
+ expect(TW_MERGE_CONFIG.extend.theme.shadow).toEqual([...ETCHED_SHADOW_TOKENS]);
204
+ });
205
+ });
206
+
207
+ describe("the kicker type style", () => {
208
+ test("is ten points, tracked at 0.2em", () => {
209
+ expect(px("text-kicker")).toBe(10);
210
+ expect(CSS).toMatch(/--tracking-kicker:\s*0\.2em;/);
211
+ });
212
+
213
+ test("registers both tokens so text-kicker is read as a size, not a colour", () => {
214
+ expect(TW_MERGE_CONFIG.extend.theme.text).toContain(KICKER_TOKEN);
215
+ expect(TW_MERGE_CONFIG.extend.theme.tracking).toEqual([KICKER_TOKEN]);
216
+ });
217
+ });
218
+
185
219
  describe("the fab scale", () => {
186
220
  test("ascends in the order the registry lists it", () => {
187
221
  const values = FAB_SIZE_TOKENS.map((token) => px(`spacing-${token}`));
@@ -82,6 +82,12 @@ export const FAB_SIZE_TOKENS = ["fab-sm", "fab-md", "fab-lg"] as const;
82
82
  * `borderCurve`. Unregistered, tailwind-merge reads it as a border *colour*,
83
83
  * and `border-continuous border-border` loses one of the two.
84
84
  */
85
+ /** Names of the shadows the etched material declares in `tokens.css`. */
86
+ export const ETCHED_SHADOW_TOKENS = ["etched", "etched-dark", "etched-primary", "focus", "focus-dark"] as const;
87
+
88
+ /** The size and tracking token behind `Text variant="kicker"`; one name for both. */
89
+ export const KICKER_TOKEN = "kicker";
90
+
85
91
  export const TW_MERGE_CONFIG = {
86
92
  extend: {
87
93
  classGroups: {
@@ -97,7 +103,9 @@ export const TW_MERGE_CONFIG = {
97
103
  ...INPUT_SIZE_TOKENS,
98
104
  ...SCREEN_SIZE_TOKENS,
99
105
  ],
100
- text: [...BUTTON_TEXT_TOKENS, ...INPUT_TEXT_TOKENS],
106
+ shadow: [...ETCHED_SHADOW_TOKENS],
107
+ text: [...BUTTON_TEXT_TOKENS, ...INPUT_TEXT_TOKENS, KICKER_TOKEN],
108
+ tracking: [KICKER_TOKEN],
101
109
  },
102
110
  },
103
111
  } as const;