feat(htmx-css): ToggleGroup support + padding/primary parity

Generator (scripts/generate-htmx-css.ts): track `viaVariants` per slot so
slots that compose another component's variant system (e.g. ToggleGroupItem
via toggleVariants) inherit the referenced CVA's base + variant rules under
their own selector. Previously toggle-group-item's CSS contained only its
override classes, shipping with no padding/height/hover/active state.

Toggle (components/ui/toggle.tsx):
  - data-[state=on] now uses bg-primary (orange) instead of bg-accent (grey),
    matching every other "commit" affordance in the palette.
  - Horizontal padding aligned with Button: px-4/px-3/px-6 per size, plus
    has-[>svg]:px-* for icon-only toggles.

ToggleGroup (components/ui/toggle-group.tsx): drop min-w-0 flex-1 shrink-0
from the item override. Items now size to content instead of being clamped
into equal narrow columns where longer labels overflowed the bg box.

Showcase: add ToggleGroup section to the React page (component-matrix.tsx)
and 1:1 HTMX mirror (public/htmx.html) with a new JS bridge branch for
single/multi-select. compare-all.sh extended with the new section; 22/22
pass at ≥99.97%.

Docs: GAPS.md captures the generator gap, overflow root cause, color
rationale, and padding parity with before/after numbers.
This commit is contained in:
2026-04-24 14:43:55 -06:00
parent 928fdd8f75
commit 90930d8f78
9 changed files with 391 additions and 29 deletions

View File

@@ -50,6 +50,15 @@ type SlotExtract = {
sourceFile: string
slot: string
classes: string
/**
* Names of CVA functions referenced inside the slot's `cn(...)` call — e.g.
* `cn(toggleVariants({variant,size}), 'rounded-none ...')` on
* ToggleGroupItem records `['toggleVariants']`. Used to emit the
* referenced CVA's base + variant rules under this slot's selector, so
* slots that *compose* another component's variant system (instead of
* declaring their own cva) still inherit its padding/height/states.
*/
viaVariants: string[]
}
type Warning = { sourceFile: string; message: string }
@@ -156,6 +165,7 @@ function extractSlot(
): void {
let slot: string | null = null
let classes: string | null = null
const viaVariants: string[] = []
const attrs = element.attributes.properties
for (const attr of attrs) {
@@ -173,13 +183,22 @@ function extractSlot(
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
classes = (expr as ts.StringLiteral).text
} else if (ts.isCallExpression(expr)) {
// cn(...) or xVariants(...) — we extract the first string-literal arg
// Variant calls are already covered by CVA extraction; cn's first literal
// is the static baseline.
const callName = expr.expression.getText()
if (callName === 'cn' || callName.endsWith('Variants')) {
// First string-literal arg is the static class baseline.
const first = expr.arguments.find((a) => getStringLiteral(a) !== null)
if (first) classes = getStringLiteral(first)
// Any *call* arg whose callee is a `xVariants` identifier means this
// slot inherits from another component's variant system (e.g.
// ToggleGroupItem: `cn(toggleVariants({variant,size}), 'rounded-none...')`).
// Record the names so the emitter can pull in those CVAs' base + variants.
for (const arg of expr.arguments) {
if (!ts.isCallExpression(arg)) continue
const argCallee = arg.expression
if (ts.isIdentifier(argCallee) && argCallee.text.endsWith('Variants')) {
viaVariants.push(argCallee.text)
}
}
}
}
}
@@ -187,13 +206,7 @@ function extractSlot(
}
if (slot) {
if (classes === null) {
// data-slot is present but no static class extractable (maybe dynamic).
// We still record the slot with empty classes so rule ordering is preserved.
slotExtracts.push({ sourceFile, slot, classes: '' })
} else {
slotExtracts.push({ sourceFile, slot, classes })
}
slotExtracts.push({ sourceFile, slot, classes: classes ?? '', viaVariants })
}
}
@@ -313,7 +326,9 @@ function emitCss(): string {
// in the same file uses `data-slot="x"`. If ambiguous, fall back to stripping
// "Variants" and kebab-casing.
const cvaBySlot = new Map<string, CvaExtract>()
const cvaByName = new Map<string, CvaExtract>()
for (const cva of cvaExtracts) {
cvaByName.set(cva.variableName, cva)
const stripped = cva.variableName.replace(/Variants$/, '')
const slot = stripped
.replace(/([a-z])([A-Z])/g, '$1-$2')
@@ -321,13 +336,30 @@ function emitCss(): string {
cvaBySlot.set(slot, cva)
}
// Aggregate viaVariants per slot (a slot may appear in multiple JSX sites).
const slotVia = new Map<string, Set<string>>()
for (const s of slotExtracts) {
if (s.viaVariants.length === 0) continue
if (!slotVia.has(s.slot)) slotVia.set(s.slot, new Set())
for (const v of s.viaVariants) slotVia.get(s.slot)!.add(v)
}
// Emit slots in sorted order for stable output.
const allSlots = Array.from(
new Set([...slotMap.keys(), ...cvaBySlot.keys()]),
).sort()
for (const slot of allSlots) {
const cva = cvaBySlot.get(slot)
const selfCva = cvaBySlot.get(slot)
// Gather CVAs to apply under this slot's selector: its own (if any), plus
// any CVAs referenced via `cn(xVariants(...), ...)` in the JSX (e.g.
// ToggleGroupItem inherits from toggleVariants). Dedup.
const cvas: CvaExtract[] = []
if (selfCva) cvas.push(selfCva)
for (const name of slotVia.get(slot) ?? []) {
const aliasCva = cvaByName.get(name)
if (aliasCva && !cvas.includes(aliasCva)) cvas.push(aliasCva)
}
const staticSets = slotMap.get(slot)
const staticClasses = staticSets ? Array.from(staticSets).join(' ') : ''
@@ -341,19 +373,22 @@ function emitCss(): string {
// (0,1,0) and silently drop user overrides.
const SLOT = `:where([data-slot="${slot}"])`
// Base rule: cva.base + any static classes found on the slot's JSX
// Base rule: every contributing CVA's base + any static classes from JSX.
const basePieces: string[] = []
if (cva?.base) basePieces.push(cva.base)
for (const c of cvas) if (c.base) basePieces.push(c.base)
if (staticClasses) basePieces.push(staticClasses)
const base = uniqueClasses(basePieces.join(' '))
if (base) {
lines.push(` ${SLOT} { @apply ${base}; }`)
}
// CVA variants
if (cva) {
for (const [axis, values] of Object.entries(cva.variants)) {
const defaultValue = cva.defaultVariants[axis]
// Emit variant rules for each contributing CVA, under this slot's selector.
// When a slot inherits (e.g. toggle-group-item via toggleVariants), its
// data-variant/data-size attributes on the DOM drive the inherited CVA's
// rules just like they drive the self-CVA's.
for (const c of cvas) {
for (const [axis, values] of Object.entries(c.variants)) {
const defaultValue = c.defaultVariants[axis]
const axisAttr = `data-${axis}`
for (const [key, classes] of Object.entries(values)) {
if (!classes.trim()) continue