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;