+ )
+ }
+
+ // Token mode: swatch still opens the HSV picker; chip shows the bound variable
+ // and opens a popover with the alias chain + the same hex/alpha editors.
+ return (
+
diff --git a/src/panel/token-chip.test.tsx b/src/panel/token-chip.test.tsx
new file mode 100644
index 0000000..bbd59c8
--- /dev/null
+++ b/src/panel/token-chip.test.tsx
@@ -0,0 +1,41 @@
+import * as React from 'react'
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { TokenChip } from './token-chip'
+
+afterEach(cleanup)
+
+describe('TokenChip', () => {
+ it('renders the label text', () => {
+ const { container } = render()
+ expect(container.textContent).toContain('--color-primary')
+ })
+
+ it('renders a swatch when swatchHex is set, and none when omitted', () => {
+ const withSwatch = render()
+ expect(withSwatch.container.querySelector('span[class*="size-3.5"]')).not.toBeNull()
+ cleanup()
+
+ const withoutSwatch = render()
+ expect(withoutSwatch.container.querySelector('span[class*="size-3.5"]')).toBeNull()
+ })
+
+ it('does not render the popover body at rest (closed)', () => {
+ // The popover body only mounts when the chip is open. Interactive open is
+ // validated manually in the dev app — base-ui's pointer-driven popover does
+ // not open under jsdom's synthetic events (the repo mocks it elsewhere).
+ render(
+
+
POPOVER_BODY_CONTENT
+ ,
+ )
+ expect(document.body.textContent).not.toContain('POPOVER_BODY_CONTENT')
+ })
+
+ it('renders the trigger as a button', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button).not.toBeNull()
+ expect(button?.textContent).toContain('text-base')
+ })
+})
diff --git a/src/panel/token-chip.tsx b/src/panel/token-chip.tsx
new file mode 100644
index 0000000..176cb75
--- /dev/null
+++ b/src/panel/token-chip.tsx
@@ -0,0 +1,63 @@
+import * as React from 'react'
+import { Popover } from '@base-ui/react/popover'
+import { ChevronDown } from 'lucide-react'
+import { usePortalContainer } from '../portal-container'
+import { useOutsideClickDismiss } from '../hooks/use-outside-click-dismiss'
+import { cn } from '../cn'
+
+function TokenChipPortal(props: React.ComponentPropsWithoutRef) {
+ const container = usePortalContainer()
+ return
+}
+
+export interface TokenChipProps {
+ /** Text shown in the pill, e.g. '--color-primary' or 'text-base'. */
+ label: string
+ /** Optional swatch hex (6 chars, no #). Render a swatch when present. */
+ swatchHex?: string
+ /** Popover body — chain, resolved value, editable inputs, etc. */
+ children?: React.ReactNode
+ className?: string
+}
+
+export function TokenChip({ label, swatchHex, children, className }: TokenChipProps) {
+ const [open, setOpen] = React.useState(false)
+ const popupRef = React.useRef(null)
+ const triggerRef = React.useRef(null)
+
+ useOutsideClickDismiss(open, () => setOpen(false), [popupRef, triggerRef])
+
+ return (
+
+ }
+ className={cn(
+ 'flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border-0 bg-muted px-2 text-xs text-foreground hover:bg-muted-foreground/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
+ className,
+ )}
+ >
+ {swatchHex && (
+
+ )}
+ {label}
+
+
+ {children != null && (
+
+
+
+ {children}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/panel/typography-inputs.test.tsx b/src/panel/typography-inputs.test.tsx
new file mode 100644
index 0000000..1289fd9
--- /dev/null
+++ b/src/panel/typography-inputs.test.tsx
@@ -0,0 +1,84 @@
+import * as React from 'react'
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { TypographyInputs } from './typography-inputs'
+import type { TypographyProperties } from '../types'
+
+vi.mock('../ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ render }: { render: React.ReactElement }) => render,
+ TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}))
+
+vi.mock('../ui/simple-select', () => ({
+ SimpleSelect: ({
+ value,
+ onValueChange,
+ options,
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ options: Array<{ value: string; label: string }>
+ }) => (
+
+ ),
+}))
+
+afterEach(cleanup)
+
+const typography: TypographyProperties = {
+ fontFamily: 'system-ui, sans-serif',
+ fontWeight: '600',
+ fontSize: { numericValue: 16, unit: 'px', raw: '16px' },
+ lineHeight: { numericValue: 28, unit: 'px', raw: '28px' },
+ letterSpacing: { numericValue: 0, unit: 'em', raw: '0em' },
+ textAlign: 'left',
+ textVerticalAlign: 'flex-start',
+ textDecoration: 'none',
+ textTransform: 'none',
+ fontStyle: 'normal',
+}
+
+describe('TypographyInputs token awareness', () => {
+ it('shows utility chips for attributed size, weight, and line-height', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.textContent).toContain('text-base')
+ expect(container.textContent).toContain('font-semibold')
+ expect(container.textContent).toContain('leading-7')
+ // Only letter-spacing (unattributed) stays a raw number input at the row level;
+ // size / line-height moved into their chip popovers.
+ expect(container.querySelectorAll('input[type="number"]').length).toBe(1)
+ })
+
+ it('renders the raw controls when no utility is attributed (fallback)', () => {
+ const { container } = render(
+ ,
+ )
+ // size + line-height + letter-spacing all render as raw number inputs.
+ expect(container.querySelectorAll('input[type="number"]').length).toBe(3)
+ expect(container.textContent).not.toContain('text-base')
+ })
+
+ it('attributes arbitrary font-size values', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.textContent).toContain('text-[17px]')
+ })
+})
diff --git a/src/panel/typography-inputs.tsx b/src/panel/typography-inputs.tsx
index e102d9d..c53e039 100644
--- a/src/panel/typography-inputs.tsx
+++ b/src/panel/typography-inputs.tsx
@@ -3,6 +3,8 @@ import { Button } from '../ui/button'
import { SimpleSelect } from '../ui/simple-select'
import type { TypographyPropertyKey, TypographyProperties, CSSPropertyValue } from '../types'
import { NumberInput, Tip } from './shared'
+import { typographyTokenForProperty } from '../utils/design-tokens'
+import { TokenChip } from './token-chip'
import {
Type,
AlignLeft,
@@ -47,9 +49,35 @@ export const FONT_WEIGHTS = [
interface TypographyInputsProps {
typography: TypographyProperties
onUpdate: (key: TypographyPropertyKey, value: CSSPropertyValue | string) => void
+ classList?: string[]
}
-export function TypographyInputs({ typography, onUpdate }: TypographyInputsProps) {
+/**
+ * Renders the raw control inline when no Tailwind utility is attributed, or a
+ * token chip whose popover contains that same raw control when one is.
+ */
+function TypographyField({
+ token,
+ children,
+}: {
+ token: string | null
+ children: React.ReactNode
+}) {
+ if (!token) return <>{children}>
+ return (
+
+