@carbonid1/design-system 4.0.0 → 4.1.0

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/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # @carbonid1/design-system
2
2
 
3
- Shared React UI primitives themed via [`@carbonid1/tailwind-config`](../tailwind-config).
3
+ Shared React UI primitives + Tailwind v4 theme presets (`reader`, `dashboard`) + postcss config.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- pnpm add @carbonid1/design-system @carbonid1/tailwind-config react react-dom @base-ui/react class-variance-authority clsx tailwind-merge lucide-react sonner next-themes react-hotkeys-hook
8
+ pnpm add @carbonid1/design-system react react-dom @base-ui/react class-variance-authority clsx tailwind-merge lucide-react sonner next-themes react-hotkeys-hook
9
9
  ```
10
10
 
11
11
  And Tailwind CSS v4 (with `tw-animate-css` + `shadcn/tailwind.css` if you want matching utilities/base):
@@ -24,7 +24,7 @@ In your entry CSS (Next.js: `src/app/globals.css`):
24
24
  @import 'tailwindcss';
25
25
  @import 'tw-animate-css';
26
26
  @import 'shadcn/tailwind.css';
27
- @import '@carbonid1/tailwind-config/reader';
27
+ @import '@carbonid1/design-system/themes/reader';
28
28
  ```
29
29
 
30
30
  ### 2. Transpile the package in Next.js
@@ -84,8 +84,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
84
84
 
85
85
  ## Theming
86
86
 
87
- Components use semantic color utilities (`bg-primary`, `text-muted-foreground`, etc.) that resolve via `@carbonid1/tailwind-config`. Switching theme → swap the tailwind-config import. Dark mode is toggled by adding `class="dark"` to a root ancestor (`ThemeProvider` handles this).
87
+ Components use semantic color utilities (`bg-primary`, `text-muted-foreground`, etc.) that resolve via the imported theme preset. Switching theme → swap the `@carbonid1/design-system/themes/*` import. Dark mode is toggled by adding `class="dark"` to a root ancestor (`ThemeProvider` handles this).
88
88
 
89
89
  ## Build
90
90
 
91
- No build step — package ships raw `.tsx` via `files: ["src"]`. Consumers transpile at build time. Stories (`*.stories.tsx`) and tests (`*.vi.{ts,tsx}`) are excluded from the published tarball.
91
+ No build step — package ships raw `.tsx`. Consumers transpile at build time. Stories (`*.stories.tsx`) and tests (`*.test.{ts,tsx}`) are excluded from the published tarball.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carbonid1/design-system",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Shared React UI primitives + design tokens (themes, postcss config)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,9 @@
10
10
  "license": "MIT",
11
11
  "author": "Andrew Korin",
12
12
  "type": "module",
13
+ "sideEffects": [
14
+ "**/*.css"
15
+ ],
13
16
  "exports": {
14
17
  ".": "./src/index.ts",
15
18
  "./themes/reader": "./themes/reader.css",
@@ -67,9 +70,7 @@
67
70
  "tailwindcss": "^4.2.2",
68
71
  "vite": "^8.0.8",
69
72
  "vitest": "^4.1.4",
70
- "@carbonid1/tsconfig": "0.2.0",
71
- "@carbonid1/vitest-config": "0.1.0",
72
- "@carbonid1/storybook-config": "0.1.0"
73
+ "@carbonid1/tsconfig": "0.2.1"
73
74
  },
74
75
  "scripts": {
75
76
  "storybook": "storybook dev -p 6006",
@@ -0,0 +1,159 @@
1
+ 'use client'
2
+
3
+ import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
4
+ import { ChevronRight } from 'lucide-react'
5
+ import type { ComponentPropsWithoutRef, Ref } from 'react'
6
+ import { cn } from '../helpers/cn/cn'
7
+
8
+ type BasePopupProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>
9
+ type BasePositionerProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>
10
+ type BaseItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Item>
11
+ type BaseSeparatorProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>
12
+ type BaseGroupLabelProps = ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>
13
+ type BaseSubmenuTriggerProps = ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger>
14
+ type BaseCheckboxItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>
15
+ type BaseRadioItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>
16
+
17
+ type ItemVariant = 'default' | 'destructive'
18
+
19
+ type PositionerProps = BasePositionerProps & { ref?: Ref<HTMLDivElement> }
20
+ type PopupProps = BasePopupProps & { ref?: Ref<HTMLDivElement> }
21
+ type SeparatorProps = BaseSeparatorProps & { ref?: Ref<HTMLDivElement> }
22
+ type GroupLabelProps = BaseGroupLabelProps & { ref?: Ref<HTMLDivElement> }
23
+ type ItemProps = BaseItemProps & { ref?: Ref<HTMLDivElement>; variant?: ItemVariant }
24
+ type CheckboxItemProps = BaseCheckboxItemProps & { ref?: Ref<HTMLDivElement> }
25
+ type RadioItemProps = BaseRadioItemProps & { ref?: Ref<HTMLDivElement> }
26
+ type SubmenuTriggerProps = BaseSubmenuTriggerProps & { ref?: Ref<HTMLDivElement> }
27
+
28
+ const popupClasses =
29
+ 'bg-popover text-popover-foreground border-border z-50 min-w-45 origin-[var(--transform-origin)] rounded-lg border py-1 shadow-lg outline-hidden transition-[transform,opacity] data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0'
30
+
31
+ const itemBaseClasses =
32
+ 'relative flex h-7 cursor-default items-center gap-1.5 px-2.5 text-[0.8rem] outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4 data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
33
+
34
+ const itemVariantClasses: Record<ItemVariant, string> = {
35
+ default:
36
+ 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
37
+ destructive:
38
+ 'text-destructive data-[highlighted]:bg-destructive/10 data-[highlighted]:text-destructive',
39
+ }
40
+
41
+ const Positioner = ({ className, sideOffset = 4, ...props }: PositionerProps) => (
42
+ <BaseContextMenu.Positioner
43
+ sideOffset={sideOffset}
44
+ className={cn('z-50 outline-hidden', className)}
45
+ {...props}
46
+ />
47
+ )
48
+
49
+ const Popup = ({ className, ...props }: PopupProps) => (
50
+ <BaseContextMenu.Popup className={cn(popupClasses, className)} {...props} />
51
+ )
52
+
53
+ const Item = ({ className, variant = 'default', ...props }: ItemProps) => (
54
+ <BaseContextMenu.Item
55
+ className={cn(itemBaseClasses, itemVariantClasses[variant], className)}
56
+ {...props}
57
+ />
58
+ )
59
+
60
+ const Separator = ({ className, ...props }: SeparatorProps) => (
61
+ <BaseContextMenu.Separator
62
+ className={cn('bg-border my-1 h-px', className)}
63
+ {...props}
64
+ />
65
+ )
66
+
67
+ const GroupLabel = ({ className, ...props }: GroupLabelProps) => (
68
+ <BaseContextMenu.GroupLabel
69
+ className={cn('text-muted-foreground px-2.5 py-1 text-xs', className)}
70
+ {...props}
71
+ />
72
+ )
73
+
74
+ const CheckboxItem = ({ className, children, ...props }: CheckboxItemProps) => (
75
+ <BaseContextMenu.CheckboxItem
76
+ className={cn(itemBaseClasses, itemVariantClasses.default, 'pl-7', className)}
77
+ {...props}
78
+ >
79
+ <span
80
+ aria-hidden
81
+ className="absolute left-2 flex size-3.5 items-center justify-center"
82
+ >
83
+ <BaseContextMenu.CheckboxItemIndicator className="size-3.5">
84
+ <CheckIcon />
85
+ </BaseContextMenu.CheckboxItemIndicator>
86
+ </span>
87
+ {children}
88
+ </BaseContextMenu.CheckboxItem>
89
+ )
90
+
91
+ const RadioItem = ({ className, children, ...props }: RadioItemProps) => (
92
+ <BaseContextMenu.RadioItem
93
+ className={cn(itemBaseClasses, itemVariantClasses.default, 'pl-7', className)}
94
+ {...props}
95
+ >
96
+ <span
97
+ aria-hidden
98
+ className="absolute left-2 flex size-3.5 items-center justify-center"
99
+ >
100
+ <BaseContextMenu.RadioItemIndicator className="size-3.5">
101
+ <DotIcon />
102
+ </BaseContextMenu.RadioItemIndicator>
103
+ </span>
104
+ {children}
105
+ </BaseContextMenu.RadioItem>
106
+ )
107
+
108
+ const SubmenuTrigger = ({ className, children, ...props }: SubmenuTriggerProps) => (
109
+ <BaseContextMenu.SubmenuTrigger
110
+ className={cn(
111
+ itemBaseClasses,
112
+ itemVariantClasses.default,
113
+ 'data-popup-open:bg-accent data-popup-open:text-accent-foreground pr-2',
114
+ className,
115
+ )}
116
+ {...props}
117
+ >
118
+ {children}
119
+ <ChevronRight className="ml-auto size-4 opacity-60" aria-hidden />
120
+ </BaseContextMenu.SubmenuTrigger>
121
+ )
122
+
123
+ const CheckIcon = () => (
124
+ <svg viewBox="0 0 14 14" fill="none" className="size-3.5">
125
+ <path
126
+ d="M3 7.5 6 10.5 11 4.5"
127
+ stroke="currentColor"
128
+ strokeWidth="1.75"
129
+ strokeLinecap="round"
130
+ strokeLinejoin="round"
131
+ />
132
+ </svg>
133
+ )
134
+
135
+ const DotIcon = () => (
136
+ <svg viewBox="0 0 14 14" className="size-3.5">
137
+ <circle cx="7" cy="7" r="3" fill="currentColor" />
138
+ </svg>
139
+ )
140
+
141
+ export const ContextMenu = {
142
+ Root: BaseContextMenu.Root,
143
+ Trigger: BaseContextMenu.Trigger,
144
+ Portal: BaseContextMenu.Portal,
145
+ Backdrop: BaseContextMenu.Backdrop,
146
+ Positioner,
147
+ Popup,
148
+ Arrow: BaseContextMenu.Arrow,
149
+ Group: BaseContextMenu.Group,
150
+ GroupLabel,
151
+ Separator,
152
+ Item,
153
+ LinkItem: BaseContextMenu.LinkItem,
154
+ CheckboxItem,
155
+ RadioGroup: BaseContextMenu.RadioGroup,
156
+ RadioItem,
157
+ SubmenuRoot: BaseContextMenu.SubmenuRoot,
158
+ SubmenuTrigger,
159
+ }
@@ -0,0 +1,51 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { ProgressRing } from './ProgressRing'
4
+ import { CIRCUMFERENCE } from './ProgressRing.consts'
5
+
6
+ describe('ProgressRing', () => {
7
+ it('renders zero dashoffset at 0% progress', () => {
8
+ render(<ProgressRing progress={0} colorClass="text-primary" label="Empty" testId="ring" />)
9
+ const circle = screen.getByTestId('ring')
10
+ const offset = Number(circle.getAttribute('stroke-dashoffset'))
11
+ expect(offset).toBeCloseTo(CIRCUMFERENCE, 1)
12
+ })
13
+
14
+ it('renders correct dashoffset at 50% progress', () => {
15
+ render(<ProgressRing progress={0.5} colorClass="text-primary" label="Half" testId="ring" />)
16
+ const circle = screen.getByTestId('ring')
17
+ const offset = Number(circle.getAttribute('stroke-dashoffset'))
18
+ expect(offset).toBeCloseTo(CIRCUMFERENCE * 0.5, 1)
19
+ })
20
+
21
+ it('renders zero dashoffset at 100% progress', () => {
22
+ render(<ProgressRing progress={1} colorClass="text-success" label="Full" testId="ring" />)
23
+ const circle = screen.getByTestId('ring')
24
+ const offset = Number(circle.getAttribute('stroke-dashoffset'))
25
+ expect(offset).toBeCloseTo(0, 1)
26
+ })
27
+
28
+ it('applies colorClass to fill circle', () => {
29
+ render(<ProgressRing progress={0.5} colorClass="text-success" label="Test" testId="ring" />)
30
+ const circle = screen.getByTestId('ring')
31
+ expect(circle.classList.toString()).toContain('text-success')
32
+ })
33
+
34
+ it('applies pulse animation when animate is true', () => {
35
+ render(<ProgressRing progress={0.5} colorClass="text-primary" label="Pulsing" animate />)
36
+ const svg = screen.getByRole('img', { hidden: true })
37
+ expect(svg.classList.toString()).toContain('animate-buffer-pulse')
38
+ })
39
+
40
+ it('does not apply pulse animation when animate is false', () => {
41
+ render(<ProgressRing progress={0.5} colorClass="text-primary" label="Idle" />)
42
+ const svg = screen.getByRole('img', { hidden: true })
43
+ expect(svg.classList.toString()).not.toContain('animate-buffer-pulse')
44
+ })
45
+
46
+ it('sets aria-label from label prop', () => {
47
+ render(<ProgressRing progress={0.5} colorClass="text-primary" label="42 paragraphs ready" />)
48
+ const svg = screen.getByRole('img', { hidden: true })
49
+ expect(svg.getAttribute('aria-label')).toBe('42 paragraphs ready')
50
+ })
51
+ })
@@ -0,0 +1,129 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+ import { StrictMode } from 'react'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { Select } from './Select'
5
+ import type { SelectGroup } from './Select.types'
6
+
7
+ const options = [
8
+ { value: 'a', label: 'Alpha' },
9
+ { value: 'b', label: 'Bravo' },
10
+ { value: 'c', label: 'Charlie' },
11
+ ]
12
+
13
+ const groups: SelectGroup[] = [
14
+ {
15
+ label: 'Letters',
16
+ options: [
17
+ { value: 'a', label: 'Alpha' },
18
+ { value: 'b', label: 'Bravo' },
19
+ ],
20
+ },
21
+ {
22
+ label: 'Numbers',
23
+ options: [
24
+ { value: '1', label: 'One' },
25
+ { value: '2', label: 'Two' },
26
+ ],
27
+ },
28
+ ]
29
+
30
+ const renderWith = (ui: React.ReactElement) => render(<StrictMode>{ui}</StrictMode>)
31
+
32
+ describe('Select', () => {
33
+ it('renders selected label and chevron in button', () => {
34
+ renderWith(<Select options={options} value="b" onChange={vi.fn()} />)
35
+
36
+ const button = screen.getByRole('button', { name: /bravo/i })
37
+ expect(button).toBeInTheDocument()
38
+ expect(button.querySelector('svg')).toBeInTheDocument()
39
+ })
40
+
41
+ it('opens menu on click showing all options', () => {
42
+ renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
43
+
44
+ fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
45
+
46
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
47
+ expect(screen.getByRole('option', { name: /alpha/i })).toBeInTheDocument()
48
+ expect(screen.getByRole('option', { name: /bravo/i })).toBeInTheDocument()
49
+ expect(screen.getByRole('option', { name: /charlie/i })).toBeInTheDocument()
50
+ })
51
+
52
+ it('calls onChange and closes menu when an option is selected', () => {
53
+ const onChange = vi.fn()
54
+ renderWith(<Select options={options} value="a" onChange={onChange} />)
55
+
56
+ fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
57
+ fireEvent.click(screen.getByRole('option', { name: /charlie/i }))
58
+
59
+ expect(onChange).toHaveBeenCalledWith('c')
60
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
61
+ })
62
+
63
+ it('closes on Escape key', () => {
64
+ renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
65
+
66
+ const button = screen.getByRole('button', { name: /alpha/i })
67
+ fireEvent.click(button)
68
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
69
+
70
+ fireEvent.keyDown(button, { key: 'Escape' })
71
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
72
+ })
73
+
74
+ it('closes on click outside', () => {
75
+ renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
76
+
77
+ fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
78
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
79
+
80
+ fireEvent.mouseDown(document.body)
81
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
82
+ })
83
+
84
+ it('navigates with arrow keys and selects with Enter', () => {
85
+ const onChange = vi.fn()
86
+ renderWith(<Select options={options} value="a" onChange={onChange} />)
87
+
88
+ const button = screen.getByRole('button', { name: /alpha/i })
89
+ fireEvent.click(button)
90
+
91
+ fireEvent.keyDown(button, { key: 'ArrowDown' })
92
+ fireEvent.keyDown(button, { key: 'ArrowDown' })
93
+ fireEvent.keyDown(button, { key: 'Enter' })
94
+
95
+ expect(onChange).toHaveBeenCalledWith('b')
96
+ })
97
+
98
+ it('renders groups with headers', () => {
99
+ renderWith(<Select groups={groups} value="a" onChange={vi.fn()} />)
100
+
101
+ fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
102
+
103
+ expect(screen.getByText('Letters')).toBeInTheDocument()
104
+ expect(screen.getByText('Numbers')).toBeInTheDocument()
105
+ expect(screen.getByRole('option', { name: /one/i })).toBeInTheDocument()
106
+ })
107
+
108
+ it('uses renderOption for custom item rendering', () => {
109
+ renderWith(
110
+ <Select
111
+ options={options}
112
+ value="a"
113
+ onChange={vi.fn()}
114
+ renderOption={option => <span data-testid="custom">{option.label.toUpperCase()}</span>}
115
+ />,
116
+ )
117
+
118
+ fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
119
+
120
+ expect(screen.getAllByTestId('custom')).toHaveLength(3)
121
+ expect(screen.getByText('BRAVO')).toBeInTheDocument()
122
+ })
123
+
124
+ it('shows placeholder when value does not match any option', () => {
125
+ renderWith(<Select options={options} value="x" onChange={vi.fn()} placeholder="Pick one" />)
126
+
127
+ expect(screen.getByRole('button', { name: /pick one/i })).toBeInTheDocument()
128
+ })
129
+ })
@@ -0,0 +1,29 @@
1
+ import { cleanup, render, screen } from '@testing-library/react'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { Slider } from './Slider'
4
+
5
+ afterEach(cleanup)
6
+
7
+ describe('Slider', () => {
8
+ it('renders a slider with the correct value', () => {
9
+ render(<Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" />)
10
+ const slider = screen.getByRole('slider')
11
+ expect(slider).toHaveAttribute('aria-valuenow', '50')
12
+ })
13
+
14
+ it('calls onCommit when interaction ends', () => {
15
+ const onCommit = vi.fn()
16
+ render(
17
+ <Slider
18
+ value={50}
19
+ onChange={() => {}}
20
+ onCommit={onCommit}
21
+ min={0}
22
+ max={100}
23
+ aria-label="Volume"
24
+ />,
25
+ )
26
+ // Just verify it renders without error — slider drag testing is unreliable in jsdom
27
+ expect(screen.getByRole('slider')).toBeInTheDocument()
28
+ })
29
+ })
@@ -0,0 +1,31 @@
1
+ import { cleanup, render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { Switch } from './Switch'
5
+
6
+ afterEach(cleanup)
7
+
8
+ describe('Switch', () => {
9
+ it('renders as a switch with the correct checked state', () => {
10
+ render(<Switch checked={true} onChange={() => {}} aria-label="Toggle" />)
11
+ expect(screen.getByRole('switch')).toBeChecked()
12
+ })
13
+
14
+ it('calls onChange when clicked', async () => {
15
+ const user = userEvent.setup()
16
+ const onChange = vi.fn()
17
+ render(<Switch checked={false} onChange={onChange} aria-label="Toggle" />)
18
+
19
+ await user.click(screen.getByRole('switch'))
20
+ expect(onChange).toHaveBeenCalledWith(true)
21
+ })
22
+
23
+ it('does not call onChange when disabled', async () => {
24
+ const user = userEvent.setup()
25
+ const onChange = vi.fn()
26
+ render(<Switch checked={false} onChange={onChange} disabled aria-label="Toggle" />)
27
+
28
+ await user.click(screen.getByRole('switch'))
29
+ expect(onChange).not.toHaveBeenCalled()
30
+ })
31
+ })
@@ -0,0 +1,23 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { getModKey } from './getModKey'
3
+
4
+ describe('getModKey', () => {
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals()
7
+ })
8
+
9
+ it('returns "Cmd" on Mac', () => {
10
+ vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' })
11
+ expect(getModKey()).toBe('Cmd')
12
+ })
13
+
14
+ it('returns "Ctrl" on Windows', () => {
15
+ vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' })
16
+ expect(getModKey()).toBe('Ctrl')
17
+ })
18
+
19
+ it('returns "Ctrl" when navigator is undefined', () => {
20
+ vi.stubGlobal('navigator', undefined)
21
+ expect(getModKey()).toBe('Ctrl')
22
+ })
23
+ })
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ export { Slider } from './Slider/Slider'
10
10
 
11
11
  export { Switch } from './Switch/Switch'
12
12
 
13
+ export { ContextMenu } from './ContextMenu/ContextMenu'
14
+
13
15
  export { Select } from './Select/Select'
14
16
  export type {
15
17
  SelectOption,
@@ -12,6 +12,10 @@
12
12
 
13
13
  @import 'tailwindcss';
14
14
 
15
+ /* Scan design-system components so consumers pick up classes used inside
16
+ * Button/Kbd/etc. — Tailwind v4 skips node_modules by default. */
17
+ @source "../src/**/*.{ts,tsx}";
18
+
15
19
  @custom-variant dark (&:is(.dark *));
16
20
 
17
21
  @theme inline {
@@ -117,6 +121,18 @@
117
121
  --success-foreground: oklch(0.82 0.1 149);
118
122
  }
119
123
 
124
+ /* Global focus-visible ring for raw interactive elements. Primitives (Button,
125
+ * etc.) override this with their own tuned focus styling. */
126
+ @layer base {
127
+ button:focus-visible,
128
+ [role='button']:focus-visible,
129
+ a:focus-visible {
130
+ outline: 2px solid var(--primary);
131
+ outline-offset: 2px;
132
+ border-radius: 0.375rem;
133
+ }
134
+ }
135
+
120
136
  /* Keyframes referenced by the theme's --animate-* tokens */
121
137
  @keyframes ring-spin {
122
138
  to {
package/themes/reader.css CHANGED
@@ -14,6 +14,10 @@
14
14
 
15
15
  @import 'tailwindcss';
16
16
 
17
+ /* Scan design-system components so consumers pick up classes used inside
18
+ * Button/Kbd/etc. — Tailwind v4 skips node_modules by default. */
19
+ @source "../src/**/*.{ts,tsx}";
20
+
17
21
  @custom-variant dark (&:is(.dark *));
18
22
 
19
23
  @theme inline {
@@ -119,6 +123,18 @@
119
123
  --success-foreground: oklch(0.82 0.1 149);
120
124
  }
121
125
 
126
+ /* Global focus-visible ring for raw interactive elements. Primitives (Button,
127
+ * etc.) override this with their own tuned focus styling. */
128
+ @layer base {
129
+ button:focus-visible,
130
+ [role='button']:focus-visible,
131
+ a:focus-visible {
132
+ outline: 2px solid var(--primary);
133
+ outline-offset: 2px;
134
+ border-radius: 0.375rem;
135
+ }
136
+ }
137
+
122
138
  /* Keyframes referenced by the theme's --animate-* tokens */
123
139
  @keyframes ring-spin {
124
140
  to {