@podoba/react 0.0.29 → 0.0.30

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.29",
3
+ "version": "0.0.30",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -27,8 +27,8 @@
27
27
  "typecheck": "tsc --build"
28
28
  },
29
29
  "dependencies": {
30
- "@podoba/tokens": "^0.0.29",
31
- "@podoba/tailwind": "^0.0.29",
30
+ "@podoba/tokens": "^0.0.30",
31
+ "@podoba/tailwind": "^0.0.30",
32
32
  "react-aria-components": "1.18.0",
33
33
  "class-variance-authority": "0.7.1",
34
34
  "clsx": "2.1.1",
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode, useState } from 'react'
2
2
  import { Button } from './button'
3
+ import { DisplayHeading } from './text'
3
4
 
4
5
  /**
5
6
  * BrandPageHeader — the brand-workspace page header (port of gs-platform
@@ -81,7 +82,7 @@ export function BrandPageHeader({
81
82
  const isControlled = expandedProp !== undefined
82
83
  const expanded = isControlled ? expandedProp : internalExpanded
83
84
  const hasExpandable = Boolean(createHub)
84
- const Heading = `h${headingLevel}` as const
85
+ const HeadingTag = `h${headingLevel}` as const
85
86
 
86
87
  const setExpanded = (next: boolean) => {
87
88
  if (!isControlled) setInternalExpanded(next)
@@ -98,8 +99,8 @@ export function BrandPageHeader({
98
99
  .filter(Boolean)
99
100
  .join(' ')}
100
101
  >
101
- <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-4">
102
- <div className="flex min-w-0 flex-1 flex-col gap-1">
102
+ <div className="flex flex-col gap-2 sm:grid sm:grid-cols-3 sm:items-stretch sm:gap-4">
103
+ <div className="flex min-w-0 flex-1 flex-col gap-1 sm:col-span-2">
103
104
  {breadcrumbs && breadcrumbs.length > 0 ? (
104
105
  <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
105
106
  {breadcrumbs.map((crumb, i) => (
@@ -120,25 +121,27 @@ export function BrandPageHeader({
120
121
  ))}
121
122
  </nav>
122
123
  ) : null}
123
- <Heading className="text-display font-medium leading-[1.12] tracking-wide text-fg">
124
- {parentLink ? (
125
- // gs "title to go back": muted clickable parent line above the title.
126
- // `[&_a]` styles the nested router <Link> (anchor) without @app/ui
127
- // importing the router.
128
- <>
129
- <span className="text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
130
- {parentLink}
131
- </span>
132
- <br />
133
- </>
134
- ) : null}
135
- {greeting}
136
- </Heading>
124
+ <DisplayHeading asChild className="tracking-tight">
125
+ <HeadingTag>
126
+ {parentLink ? (
127
+ // gs "title to go back": muted clickable parent line above the title.
128
+ // `[&_a]` styles the nested router <Link> (anchor) without @app/ui
129
+ // importing the router.
130
+ <>
131
+ <span className="text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
132
+ {parentLink}
133
+ </span>
134
+ <br />
135
+ </>
136
+ ) : null}
137
+ {greeting}
138
+ </HeadingTag>
139
+ </DisplayHeading>
137
140
  </div>
138
141
 
139
142
  {cta ? (
140
143
  // Hero CTA spans 4 of 12 columns (one third) — the greeting takes the rest.
141
- <div className="w-full shrink-0 sm:w-1/3">{cta}</div>
144
+ <div className="h-full min-w-0">{cta}</div>
142
145
  ) : ctaLabel ? (
143
146
  <div className="shrink-0">
144
147
  {hasExpandable ? (
@@ -23,7 +23,7 @@ export const Button = uic(RACButton, {
23
23
  // hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow → active back
24
24
  // to neutral-400 (surface-inverted, no shadow).
25
25
  primary:
26
- 'bg-surface-inverted text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-surface-inverted data-[pressed]:shadow-none',
26
+ 'bg-brand-primary text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-brand-primary data-[pressed]:shadow-none',
27
27
  // secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
28
28
  // (surface-muted) + border #aba89c (fg-subtle); active bg #eceae1.
29
29
  secondary:
@@ -39,6 +39,9 @@ export const Button = uic(RACButton, {
39
39
  sm: 'py-2 px-4',
40
40
  md: 'py-3 px-6',
41
41
  lg: 'py-4 px-8',
42
+ // Task/workflow CTA: exact source 44px height, 24px inline padding,
43
+ // 17/20 medium text.
44
+ prominent: 'h-11 px-6 py-0 text-panel-heading font-medium',
42
45
  },
43
46
  },
44
47
  defaultVariants: {
@@ -49,5 +52,5 @@ export const Button = uic(RACButton, {
49
52
 
50
53
  export type ButtonProps = RACButtonProps & {
51
54
  variant?: 'primary' | 'secondary' | 'ghost' | 'destructive'
52
- size?: 'sm' | 'md' | 'lg'
55
+ size?: 'sm' | 'md' | 'lg' | 'prominent'
53
56
  }
@@ -27,8 +27,8 @@ export interface CtaPillProps {
27
27
 
28
28
  export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
29
29
  return (
30
- <div className="flex w-full items-center justify-between gap-4 rounded-lg bg-brand-secondary px-6 py-4">
31
- <p className="min-w-0 text-heading4 font-medium leading-[22px] tracking-normal text-fg-on-brand">
30
+ <div className="flex h-full min-h-16 w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green py-2.5 pr-3 pl-4.5">
31
+ <p className="min-w-0 text-heading4 font-medium leading-5 tracking-tight text-fg-on-brand">
32
32
  {lead} <span className="font-semibold">{emphasis}</span> {tail}
33
33
  </p>
34
34
  <div className="shrink-0">{children}</div>
@@ -4,8 +4,8 @@ import {
4
4
  DialogTrigger as RACDialogTrigger,
5
5
  type DialogProps as RACDialogProps,
6
6
  Heading,
7
- Modal,
8
- ModalOverlay,
7
+ Modal as RACModal,
8
+ ModalOverlay as RACModalOverlay,
9
9
  } from 'react-aria-components'
10
10
  import { uic } from '../utils/uic'
11
11
 
@@ -18,25 +18,31 @@ import { uic } from '../utils/uic'
18
18
  */
19
19
  export const DialogTrigger = RACDialogTrigger
20
20
 
21
- // gs modal backdrop gs `Dialog.module.scss` overlay: a neutral grey scrim
22
- // (`rgba(179,179,179,0.5)`) under a light `blur(2px)`.
23
- const Overlay = uic(ModalOverlay, {
21
+ // Current gs modal backdrop: warm surface-card scrim at 66% under a 10px blur.
22
+ export const ModalOverlay = uic(RACModalOverlay, {
24
23
  displayName: 'DialogOverlay',
25
24
  baseClass:
26
25
  'fixed inset-0 z-50 flex items-center justify-center p-4 ' +
27
- 'bg-[rgba(179,179,179,0.5)] backdrop-blur-[2px] ' +
28
- 'data-[entering]:animate-in data-[exiting]:animate-out',
26
+ 'bg-modal-backdrop backdrop-blur-modal-backdrop ' +
27
+ 'data-[entering]:animate-modal-overlay-in data-[exiting]:animate-modal-overlay-out',
29
28
  })
30
29
 
31
- // gs content card: white bg, 20px padding (`p-5`), 8px radius (`rounded-lg`), and
32
- // the gs `Dialog.module.scss` card shadow — a light single-layer
33
- // `0 2px 8px rgba(0,0,0,.06)`. No border: gs defines the card with the shadow alone.
34
- const StyledModal = uic(Modal, {
30
+ // gs content card: white bg, 20px padding, 8px radius and the shared two-layer
31
+ // modal elevation.
32
+ export const ModalSurface = uic(RACModal, {
35
33
  displayName: 'DialogModal',
36
34
  baseClass:
37
- 'rounded-lg bg-surface p-5 outline-none shadow-[0px_2px_8px_0px_rgba(0,0,0,0.06)]',
35
+ 'rounded-lg bg-surface p-5 outline-none shadow-modal-surface ' +
36
+ 'data-[entering]:animate-modal-surface-in data-[exiting]:animate-modal-surface-out',
38
37
  })
39
38
 
39
+ /**
40
+ * Unstyled accessible dialog body for edge-to-edge / split modal compositions.
41
+ * Pair only with the shared {@link ModalOverlay} and {@link ModalSurface}; ordinary
42
+ * form and confirmation dialogs should use the composed {@link Dialog}.
43
+ */
44
+ export const ModalDialog = RACDialog
45
+
40
46
  /**
41
47
  * Modal width presets. `md` (default) is the form/confirm dialog (gs DialogContent
42
48
  * default, 586px); `sm` is a tighter confirm; `lg`/`xl` host wider, taller content
@@ -126,18 +132,14 @@ export const Dialog = ({
126
132
  // when omitted the overlay reads its state from an enclosing DialogTrigger.
127
133
  // Either way the RACDialog render-prop `close` resolves against the active
128
134
  // overlay state, so `close()` works in both modes.
129
- <Overlay
130
- // Full-screen is an immersive takeover: swap the light 2px scrim for a
131
- // heavier backdrop blur so the page behind reads as clearly blurred around
132
- // the near-fullscreen canvas. (tailwind-merge dedupes the base blur/scrim.)
133
- className={isFlex ? "bg-black/25 backdrop-blur-lg" : undefined}
135
+ <ModalOverlay
134
136
  isOpen={isOpen}
135
137
  defaultOpen={defaultOpen}
136
138
  onOpenChange={onOpenChange}
137
139
  isDismissable={isDismissable}
138
140
  >
139
- <StyledModal className={SIZE_CLASS[size]}>
140
- <RACDialog
141
+ <ModalSurface className={SIZE_CLASS[size]}>
142
+ <ModalDialog
141
143
  {...props}
142
144
  className={isFlex ? 'flex min-h-0 flex-1 flex-col outline-none' : 'outline-none'}
143
145
  >
@@ -200,8 +202,8 @@ export const Dialog = ({
200
202
  )}
201
203
  </>
202
204
  )}
203
- </RACDialog>
204
- </StyledModal>
205
- </Overlay>
205
+ </ModalDialog>
206
+ </ModalSurface>
207
+ </ModalOverlay>
206
208
  )
207
209
  }
@@ -20,20 +20,15 @@ import { uic } from '../utils/uic'
20
20
  */
21
21
  const StyledInput = uic(RACInput, {
22
22
  displayName: 'InputControl',
23
- // White fill so the field reads as editable. The gs original used a cream
24
- // (#f7f6f2 surface-card) fill, but our Card surface is ALSO surface-card, so
25
- // a filled field inside a card vanished and read as disabled. Inverted: active
26
- // = white (surface), disabled = the muted cream. border #eceae1 → border ·
27
- // hover #aba89c → fg-subtle · focus #75e7b8 → brand-green · error → danger.
28
- // Identical to textarea.tsx's filled-field skin; `fieldSize` adds the
29
- // single-line height (gs sizes the field via padding only).
23
+ // gs source: borderless cream fill, 8px radius, 14/18 text and 16px inline
24
+ // padding. Focus keeps an explicit ring for keyboard accessibility.
30
25
  baseClass:
31
- 'w-full rounded-lg border border-border bg-surface px-4 text-small text-fg ' +
26
+ 'w-full rounded-lg border-0 bg-surface-card px-4 text-small text-fg ' +
32
27
  'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
33
- 'data-[hovered]:border-fg-subtle ' +
34
- 'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
35
- 'data-[invalid]:border-danger data-[invalid]:ring-danger ' +
36
- 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
28
+ 'data-[hovered]:bg-surface-muted ' +
29
+ 'data-[focused]:ring-2 data-[focused]:ring-ring ' +
30
+ 'data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
31
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
37
32
  variants: {
38
33
  // `fieldSize` (not `size`) to avoid colliding with the native <input size>
39
34
  // attribute, which RAC's Input inherits (a numeric prop).
@@ -41,6 +36,7 @@ const StyledInput = uic(RACInput, {
41
36
  sm: 'h-8',
42
37
  md: 'h-10',
43
38
  lg: 'h-12',
39
+ tall: 'h-control-tall',
44
40
  },
45
41
  },
46
42
  defaultVariants: {
@@ -56,13 +52,17 @@ export type InputProps = TextFieldProps & {
56
52
  /** Error message; pass a string for a static error or rely on validation. */
57
53
  errorMessage?: string
58
54
  placeholder?: string
59
- size?: 'sm' | 'md' | 'lg'
55
+ size?: 'sm' | 'md' | 'lg' | 'tall'
56
+ /** Optional class for the TextField root. */
57
+ rootClassName?: string
58
+ /** Optional class for the inner native input (for product-specific composition). */
59
+ inputClassName?: string
60
60
  }
61
61
 
62
- export const Input = ({ label, description, errorMessage, placeholder, size, ...props }: InputProps) => (
63
- <TextField {...props} className="flex w-full flex-col gap-2">
64
- <Label className="text-heading5 font-medium text-fg">{label}</Label>
65
- <StyledInput placeholder={placeholder} fieldSize={size} />
62
+ export const Input = ({ label, description, errorMessage, placeholder, size, rootClassName, inputClassName, ...props }: InputProps) => (
63
+ <TextField {...props} className={`flex w-full flex-col gap-3 ${rootClassName ?? ''}`}>
64
+ <Label className="text-panel-heading font-medium text-fg">{label}</Label>
65
+ <StyledInput className={inputClassName} placeholder={placeholder} fieldSize={size} />
66
66
  {description ? (
67
67
  <Text slot="description" className="text-label text-fg-muted">
68
68
  {description}
@@ -36,6 +36,10 @@ export type SectionTabsProps = {
36
36
  resetLabel?: string
37
37
  /** Glyph for the reset control; defaults to a small grid icon. */
38
38
  resetIcon?: ReactNode
39
+ /** Visible reset content. When set, renders a source-style text tab (e.g. "Summary"). */
40
+ resetContent?: ReactNode
41
+ /** Whether the reset/overview destination is the currently selected section. */
42
+ isResetSelected?: boolean
39
43
  className?: string
40
44
  }
41
45
 
@@ -49,12 +53,12 @@ const GridGlyph = () => (
49
53
  )
50
54
 
51
55
  const tabBase =
52
- 'inline-flex h-7 items-center gap-2 rounded-sm px-3 text-compact text-fg-muted outline-none ' +
53
- 'transition-colors duration-[120ms] ease-[ease] data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
56
+ 'inline-flex h-7 shrink-0 items-center gap-2 rounded-sm px-nav-x py-1.5 text-compact font-normal text-fg-muted outline-none ' +
57
+ 'transition-colors duration-150 ease-in-out data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
54
58
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
55
59
  'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:bg-transparent data-[disabled]:text-fg-muted'
56
60
 
57
- const tabActive = 'bg-surface-muted font-medium text-fg'
61
+ const tabActive = 'bg-surface-muted text-fg'
58
62
 
59
63
  export function SectionTabs({
60
64
  tabs,
@@ -64,19 +68,37 @@ export function SectionTabs({
64
68
  onReset,
65
69
  resetLabel = 'Reset',
66
70
  resetIcon,
71
+ resetContent,
72
+ isResetSelected = false,
67
73
  className,
68
74
  }: SectionTabsProps) {
69
75
  return (
70
- <div className={['flex flex-wrap items-center gap-1', className].filter(Boolean).join(' ')} role="group">
76
+ <div
77
+ className={[
78
+ 'flex min-h-9 w-full flex-nowrap items-start gap-2 overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:w-2/3 lg:flex-wrap lg:overflow-visible lg:pb-0',
79
+ className,
80
+ ]
81
+ .filter(Boolean)
82
+ .join(' ')}
83
+ role="group"
84
+ >
71
85
  {onReset ? (
72
- <Button
73
- variant="ghost"
74
- aria-label={resetLabel}
75
- onPress={onReset}
76
- className="h-7 w-7 rounded-sm p-0 text-fg-muted data-[hovered]:text-fg"
77
- >
78
- {resetIcon ?? <GridGlyph />}
79
- </Button>
86
+ <div className="sticky left-0 z-10 shrink-0 bg-surface pr-1 lg:static lg:bg-transparent lg:pr-0">
87
+ <Button
88
+ variant="ghost"
89
+ aria-label={resetLabel}
90
+ aria-pressed={isResetSelected}
91
+ onPress={onReset}
92
+ className={[
93
+ resetContent ? tabBase : 'h-7 w-7 rounded-sm p-0 text-fg-muted data-[hovered]:text-fg',
94
+ isResetSelected ? tabActive : '',
95
+ ]
96
+ .filter(Boolean)
97
+ .join(' ')}
98
+ >
99
+ {resetContent ?? resetIcon ?? <GridGlyph />}
100
+ </Button>
101
+ </div>
80
102
  ) : null}
81
103
  {tabs.map((tab) => {
82
104
  const selected = active === tab.key
@@ -30,17 +30,15 @@ import { useInFocusOverlay } from './focus-context'
30
30
  */
31
31
  const SelectTrigger = uic(RACButton, {
32
32
  displayName: 'SelectTrigger',
33
- // Sized to match the other form fields (Input / ComboBox): h-12, 16px side
34
- // padding, 8px radius. White fill + border so the trigger doesn't vanish inside
35
- // a Card (also surface-card) and read as disabled; hover darkens the border.
36
- // Error → 2px danger ring driven off the RACSelect root's `data-invalid`.
33
+ // Exact gs source control: 58px tall, 20px inline padding, borderless cream
34
+ // fill and 8px radius.
37
35
  baseClass:
38
- 'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-4 ' +
36
+ 'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border-0 bg-surface-card px-5 ' +
39
37
  'text-small text-fg outline-none transition-colors ' +
40
- 'data-[hovered]:border-fg-subtle ' +
38
+ 'data-[hovered]:bg-surface-muted ' +
41
39
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
42
- 'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
43
- 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
40
+ 'group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
41
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
44
42
  })
45
43
 
46
44
  export const SelectItem = uic(ListBoxItem, {
@@ -66,6 +64,10 @@ export type SelectProps<T extends object> = RACSelectProps<T> & {
66
64
  */
67
65
  placeholder: string
68
66
  children: ReactNode
67
+ /** Optional class for the Select root. */
68
+ rootClassName?: string
69
+ /** Optional class for the trigger (for product-specific composition). */
70
+ triggerClassName?: string
69
71
  }
70
72
 
71
73
  export const Select = <T extends object>({
@@ -74,6 +76,8 @@ export const Select = <T extends object>({
74
76
  errorMessage,
75
77
  placeholder,
76
78
  children,
79
+ rootClassName,
80
+ triggerClassName,
77
81
  ...props
78
82
  }: SelectProps<T>) => {
79
83
  // In a focus overlay, show the options inline (seamless) instead of a popover.
@@ -91,8 +95,8 @@ export const Select = <T extends object>({
91
95
  const err = <FieldError className="text-label text-danger">{errorMessage}</FieldError>
92
96
 
93
97
  return (
94
- <RACSelect {...props} placeholder={placeholder} className="group flex flex-col gap-2">
95
- <Label className="text-heading5 font-medium text-fg">{label}</Label>
98
+ <RACSelect {...props} placeholder={placeholder} className={`group flex flex-col gap-3 ${rootClassName ?? ''}`}>
99
+ <Label className="text-panel-heading font-medium text-fg">{label}</Label>
96
100
  {inFocus ? (
97
101
  <>
98
102
  {listbox}
@@ -101,7 +105,7 @@ export const Select = <T extends object>({
101
105
  </>
102
106
  ) : (
103
107
  <>
104
- <SelectTrigger>
108
+ <SelectTrigger className={triggerClassName}>
105
109
  <SelectValue className="data-[placeholder]:text-fg-muted" />
106
110
  {/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
107
111
  <svg
@@ -20,6 +20,25 @@ import { uic } from '../utils/uic'
20
20
  * no i18n.
21
21
  */
22
22
 
23
+ /**
24
+ * The large product heading from the GS reference UI (`label-1` in its original
25
+ * token vocabulary): 30px / 32px / medium with -2px tracking.
26
+ *
27
+ * Kept separate from the 28px semantic Heading ramp because it is a reusable
28
+ * product-display treatment, not a document-outline level. Use `asChild` to project
29
+ * it onto the correct h1–h6 element.
30
+ */
31
+ export const DisplayHeading = uic('h2', {
32
+ displayName: 'DisplayHeading',
33
+ baseClass: 'text-display-large font-medium text-fg',
34
+ })
35
+
36
+ /** Compact 17px / 20px section title used in workflow and dialog panels. */
37
+ export const PanelHeading = uic('h3', {
38
+ displayName: 'PanelHeading',
39
+ baseClass: 'text-panel-heading font-medium text-fg',
40
+ })
41
+
23
42
  export const Text = uic('span', {
24
43
  displayName: 'Text',
25
44
  baseClass: 'text-fg',
package/src/utils/uic.ts CHANGED
@@ -58,12 +58,15 @@ const twMerge = extendTailwindMerge({
58
58
  'caption',
59
59
  'label',
60
60
  'compact',
61
+ 'small',
61
62
  'callout',
62
63
  'body',
63
64
  'subtitle',
64
65
  'title',
65
66
  'headline',
66
67
  'display',
68
+ 'display-large',
69
+ 'panel-heading',
67
70
  // Heading ramp (size + line-height).
68
71
  'heading1',
69
72
  'heading2',
@@ -75,7 +78,7 @@ const twMerge = extendTailwindMerge({
75
78
  ],
76
79
  },
77
80
  theme: {
78
- spacing: ['nav-x'],
81
+ spacing: ['nav-x', 'control-tall'],
79
82
  // Custom card/panel radius key (rounded-panel) → dedupes against other
80
83
  // rounded-* utilities. xl/2xl are stock keys tailwind-merge already knows.
81
84
  radius: ['panel'],