diff --git a/docs/kit/json-render.md b/docs/kit/json-render.md index 9c4cfabb..f4dd19b9 100644 --- a/docs/kit/json-render.md +++ b/docs/kit/json-render.md @@ -232,6 +232,8 @@ Flex layout container. Arranges children vertically or horizontally. | `wrap` | `boolean` | `false` | Allow children to wrap onto multiple lines | | `flex` | `number \| string` | — | `flex` shorthand for the container | | `padding` | `number` | — | Padding in pixels | +| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger' \| 'info' \| 'success' \| 'warning'` | `'primary'` | Background tint (`danger`/`info`/`success`/`warning` also draw a matching border) | +| `interactive` | `boolean` | `false` | Tint the background on hover — useful for clickable-looking rows | ```ts @@ -262,6 +264,8 @@ Container with an optional title and collapsible behavior. | `title` | `string` | — | Header title. A collapsible card still shows its (empty) header without one — otherwise there'd be nothing to click | | `collapsible` | `boolean` | `false` | Whether the card can be collapsed | | `defaultCollapsed` | `boolean` | `false` | Start collapsed (when `collapsible`) | +| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger' \| 'info' \| 'success' \| 'warning'` | `'primary'` | A light background tint on the body, a stronger same-hue tint on the header bar, and a matching border (`danger`/`info`/`success`/`warning`) | +| `interactive` | `boolean` | `false` | Brighten the border on hover | | `loading` | `boolean` | `false` | Show a loading state | diff --git a/packages/core/src/client/webcomponents/index.ts b/packages/core/src/client/webcomponents/index.ts index f5751593..76bfbcd0 100644 --- a/packages/core/src/client/webcomponents/index.ts +++ b/packages/core/src/client/webcomponents/index.ts @@ -17,6 +17,7 @@ export type { JsonRenderElement, KeyValueTableProps, ProgressProps, + StackProps, SwitchProps, TabDescriptor, TabsProps, diff --git a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts index 48a4736f..a9e935b7 100644 --- a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts +++ b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts @@ -84,15 +84,18 @@ export const Gallery: Story = { } /** - * A `Card` grouping content under a titled, bordered surface. `variant` - * tints the background (`secondary`/`danger`) or leaves it untouched - * (`primary`/`ghost`); `interactive` strengthens the border on hover and - * tints each row's (`Stack`) background. `collapsible` + `defaultCollapsed` + * A `Card` grouping content under a titled, bordered surface. `secondary` + * tints the background with no border; `ghost` and `primary` are untinted. + * `info`/`success`/`warning`/`danger` each draw a light body tint, a + * stronger same-hue header bar, and a matching border. `interactive` + * strengthens the border on hover (same hue for the four semantic variants) + * and tints each row's (`Stack`) background. `collapsible` + `defaultCollapsed` * control the initial collapse state; clear `title` to confirm the header * (and its chevron) still renders without one. */ interface CardArgs { - variant: 'primary' | 'secondary' | 'ghost' | 'danger' + variant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning' + rowVariant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning' interactive: boolean title: string collapsible: boolean @@ -101,23 +104,24 @@ interface CardArgs { export const Card: StoryObj> = { argTypes: { - variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger'] }, + variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger', 'info', 'success', 'warning'] }, + rowVariant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger', 'info', 'success', 'warning'] }, interactive: { control: 'boolean' }, title: { control: 'text' }, collapsible: { control: 'boolean' }, defaultCollapsed: { control: 'boolean' }, }, - args: { variant: 'primary', interactive: false, title: 'Plugin', collapsible: true, defaultCollapsed: false }, + args: { variant: 'primary', rowVariant: 'ghost', interactive: false, title: 'Plugin', collapsible: true, defaultCollapsed: false }, render: args => renderSpec(() => ({ root: 'root', state: {}, elements: { root: { type: 'Card', props: { title: args.title, collapsible: args.collapsible, defaultCollapsed: args.defaultCollapsed, variant: args.variant, interactive: args.interactive }, children: ['body'] }, body: { type: 'Stack', props: { direction: 'column', gap: 4, padding: 4 }, children: ['row1', 'row2'] }, - row1: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', interactive: args.interactive }, children: ['t', 'badge'] }, + row1: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', variant: args.rowVariant, interactive: args.interactive }, children: ['t', 'badge'] }, t: { type: 'Text', props: { text: 'vite-plugin-inspect', variant: 'code' } }, badge: { type: 'Badge', props: { text: 'enabled', variant: 'success' } }, - row2: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', interactive: args.interactive }, children: ['t2', 'badge2'] }, + row2: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', variant: args.rowVariant, interactive: args.interactive }, children: ['t2', 'badge2'] }, t2: { type: 'Text', props: { text: 'vite-plugin-vue', variant: 'code' } }, badge2: { type: 'Badge', props: { text: 'enabled', variant: 'success' } }, }, diff --git a/packages/core/src/client/webcomponents/json-render/components/Card.ts b/packages/core/src/client/webcomponents/json-render/components/Card.ts index 45b5b0f1..bd8eaf64 100644 --- a/packages/core/src/client/webcomponents/json-render/components/Card.ts +++ b/packages/core/src/client/webcomponents/json-render/components/Card.ts @@ -1,27 +1,15 @@ import { defineComponent, h, ref } from 'vue' -import { border, borderSolid, borderStrong, colors, surfaceMuted } from './tokens' +import { border, borderSolid, variantSurface } from './tokens' import { registryProps } from './types' -export type CardVariant = 'primary' | 'secondary' | 'ghost' | 'danger' - export interface CardProps { title?: string collapsible?: boolean defaultCollapsed?: boolean - variant?: CardVariant + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning' interactive?: boolean } -// Mirrors `ContainerCard.vue`'s (packages/ui) opt-in `variant` model: `primary` -// (default) keeps today's fully transparent look, so existing specs render -// unchanged; `ghost` is the same no-fill look under a more intentional name. -const variantBackground: Record = { - primary: undefined, - secondary: surfaceMuted, - ghost: undefined, - danger: colors.danger.bg, -} - export const Card = defineComponent({ name: 'JrCard', props: registryProps<'Card', CardProps>(), @@ -34,20 +22,22 @@ export const Card = defineComponent({ collapsed.value = next toggle.emit() } + const surface = variantSurface(variant) + const borderColor = surface.border ?? border return h('div', { class: 'jr-card', style: { - border: borderSolid(border), + border: borderSolid(borderColor), borderRadius: '6px', overflow: 'hidden', - backgroundColor: variantBackground[variant], + backgroundColor: surface.background, transition: interactive ? 'border-color 0.15s ease' : undefined, }, // `interactive` strengthens the border on hover rather than tinting // the background (already set by `variant`) — same transition timing // as Stack's row hover, just on `border-color` instead of `background`. - onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = borderStrong } : undefined, - onMouseleave: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = border } : undefined, + onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = surface.hoverBorder ?? borderColor } : undefined, + onMouseleave: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = borderColor } : undefined, }, [ // Renders without a `title` too — a collapsible card needs this as its click target. (title || collapsible) && h('div', { @@ -60,7 +50,8 @@ export const Card = defineComponent({ alignItems: 'center', justifyContent: 'space-between', cursor: collapsible ? 'pointer' : undefined, - borderBottom: collapsed.value ? 'none' : borderSolid(border), + borderBottom: collapsed.value ? 'none' : borderSolid(borderColor), + backgroundColor: surface.headerBackground, userSelect: 'none', }, onClick: collapsible ? () => setCollapsed(!collapsed.value) : undefined, diff --git a/packages/core/src/client/webcomponents/json-render/components/Stack.ts b/packages/core/src/client/webcomponents/json-render/components/Stack.ts index f2008cf4..304aa368 100644 --- a/packages/core/src/client/webcomponents/json-render/components/Stack.ts +++ b/packages/core/src/client/webcomponents/json-render/components/Stack.ts @@ -1,29 +1,32 @@ -import type { RegistryComponentProps } from './types' import { defineComponent, h } from 'vue' -import { colors, surfaceMuted, surfaceSubtle } from './tokens' +import { borderSolid, variantSurface } from './tokens' +import { registryProps } from './types' + +export interface StackProps { + direction?: 'row' | 'column' + gap?: number + align?: 'start' | 'center' | 'end' | 'stretch' + justify?: 'start' | 'center' | 'end' | 'between' | 'around' + padding?: number + wrap?: boolean + flex?: number | string + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning' + interactive?: boolean +} // Map the base catalog's `align` / `justify` enums onto CSS flexbox values. const alignMap: Record = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' } const justifyMap: Record = { start: 'flex-start', center: 'center', end: 'flex-end', between: 'space-between', around: 'space-around' } -// Mirrors `ContainerCard.vue`'s (packages/ui) opt-in `variant` model: `primary` -// (default) keeps today's fully transparent look, so existing specs render -// unchanged; `ghost` is the same no-fill look under a more intentional name. -const variantBackground: Record = { - primary: undefined, - secondary: surfaceMuted, - ghost: undefined, - danger: colors.danger.bg, -} - export const Stack = defineComponent({ name: 'JrStack', - props: ['element', 'emit', 'on', 'bindings', 'loading'], - setup(ctx: RegistryComponentProps, { slots }) { + props: registryProps<'Stack', StackProps>(), + setup(ctx, { slots }) { return () => { const { direction = 'column', gap = 8, align, justify, padding, wrap, flex, variant = 'primary', interactive = false } = ctx.element.props const isHorizontal = direction === 'row' - const restingBackground = variantBackground[variant] + const surface = variantSurface(variant) + const restingBackground = surface.background return h('div', { class: 'jr-stack', style: { @@ -40,13 +43,15 @@ export const Stack = defineComponent({ // Matches Card's own borderRadius so a row's hover tint reads // consistently with the card surfaces it sits inside. borderRadius: interactive ? '6px' : undefined, - transition: interactive ? 'background-color 0.15s ease' : undefined, + // Only the four semantic variants draw a border; primary/secondary/ghost stay borderless. + border: surface.border ? borderSolid(surface.border) : undefined, + transition: interactive ? 'background-color 0.15s ease, border-color 0.15s ease' : undefined, flex: flex != null ? String(flex) : undefined, backgroundColor: restingBackground, }, // `interactive` only ever tints on hover — Stack has no click/press // semantics of its own, so rows built from it stay non-clickable. - onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = surfaceSubtle } : undefined, + onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = surface.hoverBackground ?? '' } : undefined, onMouseleave: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = restingBackground ?? '' } : undefined, }, slots.default?.()) } diff --git a/packages/core/src/client/webcomponents/json-render/components/tokens.ts b/packages/core/src/client/webcomponents/json-render/components/tokens.ts index e0564131..bae62ff1 100644 --- a/packages/core/src/client/webcomponents/json-render/components/tokens.ts +++ b/packages/core/src/client/webcomponents/json-render/components/tokens.ts @@ -16,6 +16,7 @@ export const bg = 'var(--jr-bg, inherit)' // --- Semantic palette --- +/** Badge's pill fill. Card/Stack use their own lighter tints below — a badge-strength fill would read as a heavy block over a whole card. */ export const colors = { info: { bg: 'rgba(59,130,246,0.15)', fg: 'rgb(59,130,246)' }, success: { bg: 'rgba(34,197,94,0.15)', fg: 'rgb(34,197,94)' }, @@ -41,3 +42,36 @@ export const syntaxNumber = '#dcdcaa' export function borderSolid(token = border) { return `1px solid ${token}` } + +/** Resting/hover background + border a `variant` resolves to on Card/Stack. */ +export interface VariantSurface { + background?: string + /** Card-only — its header bar reads a step stronger than the body it sits above, so a titled card is a two-tone surface rather than one flat tint. */ + headerBackground?: string + border?: string + hoverBackground?: string + hoverBorder?: string +} + +/** No semantic hue — border stays unset, hover stays a neutral grey. */ +const neutralSurfaces: Record<'primary' | 'secondary' | 'ghost', VariantSurface> = { + primary: { hoverBackground: surfaceSubtle, hoverBorder: borderStrong }, + secondary: { background: surfaceMuted, hoverBackground: surfaceSubtle, hoverBorder: borderStrong }, + ghost: { hoverBackground: surfaceSubtle, hoverBorder: borderStrong }, +} + +const semanticSurfaces: Record<'info' | 'success' | 'warning' | 'danger', Required>> = { + info: { background: 'rgba(59,130,246,0.08)', headerBackground: 'rgba(59,130,246,0.18)', border: 'rgba(59,130,246,0.25)', hoverBackground: 'rgba(59,130,246,0.25)', hoverBorder: 'rgba(59,130,246,0.6)' }, + success: { background: 'rgba(34,197,94,0.08)', headerBackground: 'rgba(34,197,94,0.18)', border: 'rgba(34,197,94,0.25)', hoverBackground: 'rgba(34,197,94,0.25)', hoverBorder: 'rgba(34,197,94,0.6)' }, + warning: { background: 'rgba(234,179,8,0.08)', headerBackground: 'rgba(234,179,8,0.18)', border: 'rgba(234,179,8,0.25)', hoverBackground: 'rgba(234,179,8,0.25)', hoverBorder: 'rgba(234,179,8,0.6)' }, + danger: { background: 'rgba(239,68,68,0.08)', headerBackground: 'rgba(239,68,68,0.18)', border: 'rgba(239,68,68,0.25)', hoverBackground: 'rgba(239,68,68,0.25)', hoverBorder: 'rgba(239,68,68,0.6)' }, +} + +/** Resolves a `variant` prop to the surface Card and Stack render at rest and on hover. */ +export function variantSurface(variant: string): VariantSurface { + if (variant in neutralSurfaces) + return neutralSurfaces[variant as keyof typeof neutralSurfaces] + if (variant in semanticSurfaces) + return semanticSurfaces[variant as keyof typeof semanticSurfaces] + return neutralSurfaces.primary +} diff --git a/packages/core/src/client/webcomponents/json-render/registry.ts b/packages/core/src/client/webcomponents/json-render/registry.ts index c2fbf437..ba542143 100644 --- a/packages/core/src/client/webcomponents/json-render/registry.ts +++ b/packages/core/src/client/webcomponents/json-render/registry.ts @@ -38,6 +38,7 @@ export type { DividerProps } from './components/Divider' export type { IconProps } from './components/Icon' export type { KeyValueTableProps } from './components/KeyValueTable' export type { ProgressProps } from './components/Progress' +export type { StackProps } from './components/Stack' export type { SwitchProps } from './components/Switch' export type { TabDescriptor, TabsProps } from './components/Tabs' export type { TextProps } from './components/Text' diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts index 66fa8566..87fa660f 100644 --- a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts @@ -19,7 +19,7 @@ export interface CardProps { title?: string; collapsible?: boolean; defaultCollapsed?: boolean; - variant?: CardVariant; + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning'; interactive?: boolean; } export interface CodeBlockProps { @@ -65,6 +65,17 @@ export interface ProgressProps { max?: number; label?: string; } +export interface StackProps { + direction?: 'row' | 'column'; + gap?: number; + align?: 'start' | 'center' | 'end' | 'stretch'; + justify?: 'start' | 'center' | 'end' | 'between' | 'around'; + padding?: number; + wrap?: boolean; + flex?: number | string; + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning'; + interactive?: boolean; +} export interface SwitchProps { value?: boolean; label?: string;