Use when building, scaffolding, or refactoring toge components (the v2 Sprout Design System). Applies when creating new .vue/.types.ts/.styles.ts/.state.ts files under src/toge/primitives/, src/toge/molecules/, or src/toge/patterns/, wiring lib/toge.ts exports, adding playground registry entries, or migrating v1 spr- components to toge pattern.
Toge is the v2 Sprout Design System — reusable, business-logic-free Vue 3 components that products consume and wire up themselves.
Core principle: Toge components are render shells. No domain logic, no internal data fetching, no state that belongs to the product. Only justified UI-only state (animations, open/close, navigation).
Working directory: /Users/maaraquel/Coding/Sprout-Design-System-Next
Every toge component follows this exact structure:
# For PRIMITIVES (visual atoms — pass the 4-question atomic test):
src/toge/primitives/{name}/
{name}.vue ← render shell, template + script setup
{name}.types.ts ← Props, Emits, Slots interfaces
{name}.styles.ts ← pure classNames function, ZERO Vue imports
{name}.state.ts ← ONLY if justified UI-only state exists
index.ts ← re-exports everything
# For MOLECULES (named UI concepts composed from 2–3 primitives):
src/toge/molecules/{name}/
{name}.vue
{name}.types.ts
{name}.styles.ts
{name}.state.ts ← ONLY if justified UI-only state exists
index.ts
# For PATTERNS (composed shells — slot-driven, UI-state owners):
src/toge/patterns/{name}/
{name}.vue
...
Sub-components live in a subfolder: src/toge/primitives/stepper/step/step.vue
Tier 1 — Primitives (src/toge/primitives/): ONE indivisible visual element
Tier 2 — Molecules (src/toge/molecules/): 2–3 atoms forming a new named UI concept
Tier 3 — Patterns (src/toge/patterns/): layout shell with product-injected slots
4-Question Test for Primitives:
Molecule vs Pattern:
One-Way Rule: primitives → tokens only molecules → primitives + sibling molecules patterns → molecules + primitives ❌ Never import upward (patterns → molecules is OK, molecules → patterns is NOT)
| Rule | Pattern |
|---|---|
| Props | withDefaults(defineProps<Props>(), {...}) — never options-style |
| Emits | defineEmits<EmitsInterface>() — TypeScript interface, not array |
| Slots | defineSlots<{slot(props: {...}): any}>() — always declare |
| v-model | defineModel<T>({ default: ... }) — never useVModel |
No any | Use unknown, generics T extends Record<string, unknown> |
| No vueuse | Use Vue 3 composition API only (ref, computed, watch, etc.) |
Emits interface syntax:
// ✅ Correct — tuple syntax
export interface ButtonEmits {
'click': [event: MouseEvent]
'update:modelValue': [value: string]
}
// ❌ Wrong — function syntax
export interface ButtonEmits {
click: (event: MouseEvent) => void
}
// {name}.styles.ts — pure function, zero Vue imports
import classNames from 'classnames'
export interface ButtonStyleProps {
disabled?: boolean
variant?: 'primary' | 'secondary'
}
export function getButtonClasses(p: ButtonStyleProps) {
return {
base: classNames('spr-flex spr-items-center spr-gap-2', {
'spr-opacity-50 spr-cursor-not-allowed': p.disabled,
}),
label: classNames('spr-body-md-semibold'),
}
}
Critical rules:
spr- prefix — spr-flex, spr-w-full, spr-gap-2 etc..styles.ts — it must be a pure JS/TS moduleclassNames from classnames<script lang="ts" setup>
import { computed } from 'vue'
import { Icon } from '@iconify/vue' // icons
import { Menu } from 'floating-vue' // dropdowns
import classNames from 'classnames' // ad-hoc classes if needed
import { getButtonClasses } from './button.styles'
import type { ButtonProps, ButtonEmits, ButtonSlots } from './button.types'
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
disabled: false,
})
const emit = defineEmits<ButtonEmits>()
defineSlots<ButtonSlots>()
const model = defineModel<string>({ default: '' }) // only if v-model needed
const classes = computed(() => getButtonClasses(props))
</script>
Icon imports: Always import { Icon } from '@iconify/vue' — never other icon libs.
Dropdown overlays: Always import { Menu } from 'floating-vue' with <template #reference> + <template #popper>.
No dayjs — use native Date math for date calculations.
| ✅ Keep (UI-only state) | ❌ Strip (business logic) |
|---|---|
isOpen ref for popovers | Internal search/filter logic |
currentMonth/Year for calendar nav | processOptions normalization |
hoveredDate for range highlight | getMonthList/getYearList emits |
collapsedState for accordion | createPinia() self-instantiation |
breadcrumb for ladderized nav | localStorage for drag/drop |
| Sort icon derivation from props | Internal sort state |
isAllSelected computed from props | Internal selection state management |
Rule: If the product should own it, strip it. Emit an event instead.
v1 components live in src/components/. When migrating to toge:
@vueuse/core — replace useVModel → defineModel, onClickOutside → floating-vue auto-hidetextField/valueField normalization — accept pre-shaped SelectOption[]disabledLocalSearch — all search is external, just emit searchcreatePinia() — snackbar anti-pattern; accept snacks prop insteadgetMonthList/getYearList emits — date pickers shouldn't fetch data<Menu> — it's a peer dep, use it for all popover overlays| Thing | Convention |
|---|---|
| Component export | TogeButton, TogeSelectMultiple |
| Component file | button.vue, select-multiple.vue |
| Types interface | ButtonProps, ButtonEmits, ButtonSlots |
| Style function | getButtonClasses(p: ButtonStyleProps) |
| State composable | useButtonState() |
| Index export | export { default as TogeButton } from './button.vue' |
For 3+ independent components, dispatch parallel agents:
Group 1: foundational (list + dropdown)
Group 2: form variants (select + select-multiple + select-ladderized)
Group 3: filter UI (filter + attribute-filter)
Group 4: table ecosystem (table + table-actions + table-pagination + sub-cells)
Group 5: date ecosystem (date-calendar-picker + date-picker + date-range + month-year)
Group 6: special cases (snackbar)
After agents complete: Wire lib/toge.ts and playground yourself — do not delegate this.
After all components in a phase are built:
// Primitive:
export { default as TogeButton } from '../src/toge/primitives/button/button.vue'
// Molecule:
export { default as TogeAvatar } from '../src/toge/molecules/avatar/avatar.vue'
// Pattern:
export { default as TogeAccordion } from '../src/toge/patterns/accordion/accordion.vue'
// Type re-exports
export type * from '../src/toge/primitives/button/button.types'
// Utility exports (if any)
export { generateTimeSlots } from '../src/toge/molecules/time-picker/time-picker.styles'
// Store exports (special cases only)
export { useSnackbarStore } from '../src/toge/stores/useSnackbarStore'
Known gotcha: defineProps defaults cannot reference locally declared variables (Vue SFC limitation). Inline the value directly in the default factory.
// ❌ Build error
const DEFAULT_OPTIONS = [10, 20, 50]
withDefaults(defineProps<Props>(), { options: () => DEFAULT_OPTIONS })
// ✅ Works
withDefaults(defineProps<Props>(), { options: () => [10, 20, 50] })
After wiring lib/toge.ts, add to src/playground/TogePlayground.vue:
Import:
// Primitive:
import TogeButton from '@/toge/primitives/button/button.vue'
// Molecule:
import TogeAvatar from '@/toge/molecules/avatar/avatar.vue'
// Pattern:
import TogeAccordion from '@/toge/patterns/accordion/accordion.vue'
Registry entry (ComponentConfig interface):
{
component: TogeButton, // Component reference
tag: 'toge-button', // kebab-case tag for code gen
propDefs: [...], // Configurable props
defaultSlot?: 'Click me', // If component has a default slot
extraProps?: { ... }, // Pre-populated data (items, options, headers)
hasModel?: true, // If component uses defineModel
modelDefault?: '', // Initial modelValue
}
Special preview cases (add v-else-if in template):
collapsible — needs named #trigger slottooltip — needs trigger elementpopper — needs #content slotdropdown — needs #reference slotsnackbar — teleports to body, show explanation noteData-heavy components use extraProps for sample data:
extraProps: {
items: [{ text: 'Option A', value: 'a' }, ...],
headers: [{ name: 'Name', field: 'name', sort: true }, ...],
tableData: [{ name: 'John Doe', ... }, ...],
}
Always run after wiring:
npm run build:toge
Expected output: ✓ built in X.XXs with no errors. Fix any errors before declaring phase complete.
Check diagnostics:
TogePlayground.vue must be zeroAny toge component that uses <Menu> from floating-vue must follow this pattern.
The DOM floating-vue generates:
.v-popper__wrapper
.v-popper__inner ← floating-vue owns this: sets border-radius:6px, bg, border, shadow
<anonymous div> ← floating-vue slot wrapper
<div class="..."> ← our classes.menu div (from .styles.ts)
<slot />
The rule:
.styles.ts — border, bg, shadow, border-radius on classes.menu using design tokens. Never hardcode these in raw CSS..v-popper__inner must be neutralized via a <style> block scoped with popper-class. Reset its border, bg, shadow to none. Set overflow: hidden.border-radius on .v-popper__inner MUST match our inner div's radius — if it's smaller, overflow:hidden clips our rounded corners at the wrong shape.popper-class="toge-{name}-popper" on <Menu> to scope the CSS override without leaking to other floating-vue instances.Template pattern:
<Menu popper-class="toge-popover-popper" ...>
<template #default><slot name="reference" /></template>
<template #popper>
<div :class="classes.menu"> <!-- owns all visual styling -->
<slot />
</div>
</template>
</Menu>
Style block pattern (popover.vue):
<style>
/* Neutralize floating-vue's .v-popper__inner — our classes.menu div owns the visual.
border-radius MUST match the token used in classes.menu (e.g. border-radius-lg = --size-250).
If they differ, overflow:hidden clips our rounded corners at the wrong shape. */
.toge-popover-popper .v-popper__inner {
border-radius: var(--size-250) !important; /* sync with classes.menu radius */
overflow: hidden !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
</style>
See reference implementation: src/toge/primitives/popover/popover.vue + popover.styles.ts
When a container wraps rounded inner content with padding, the outer radius should equal the inner radius plus the gap:
R2 (outer container) = R1 (inner content radius) + D (padding between them)
Design token values:
| Token | px |
|---|---|
border-radius-2xs | 2px |
border-radius-xs | 4px |
border-radius-sm | 6px |
border-radius-md | 8px |
border-radius-lg | 12px |
border-radius-xl | 16px |
border-radius-full | 999px |
Example (popover + list): list item R1=8px (border-radius-md) + popover padding D=8px (p-2) → popover R2 should be 16px (border-radius-xl). User may choose the nearest token that looks right visually.
| Mistake | Fix |
|---|---|
import { ref } from 'vue' in .styles.ts | Remove — styles must be pure |
p-4 instead of spr-p-4 | All Tailwind needs spr- prefix |
defineEmits(['click']) array form | Use TypeScript interface form |
Using useVModel from vueuse | Use defineModel<T>() instead |
any type | Use unknown or proper generic |
createPinia() in component | Move to store, accept data as prop |
DEFAULT_OPTIONS const in defineProps default | Inline the value directly |
Forgetting defineSlots<{...}>() | Always declare even if template uses $slots |
| Placing a composed/stateful component in primitives/ | Run the 4-question test; composed shells belong in molecules/ or patterns/ |
| Importing from patterns/ inside primitives/ | One-way rule violation — primitives must only use tokens/peer deps |
| Importing from patterns/ inside molecules/ | One-way rule violation — molecules must not import from patterns/ |
| Phase | Components | Count | Tier | Architecture | Status |
|---|---|---|---|---|---|
| Phase 1 | button, button-dropdown, badge, icon, lozenge, status, chips, avatar, collapsible, tooltip, popper | 11 | Mixed | 2-tier | ✅ Done |
| Phase 2 | input (all variants), textarea, checkbox, radio, radio-grouped, switch, slider, file-upload, progress-bar, empty-state, banner, card, logo, floating-action, calendar-cell | 23 | Mixed | 2-tier | ✅ Done |
| Phase 3 | modal, sidepanel, stacking-sidepanel, accordion, tabs, stepper, step, audit-trail, time-picker | 9 | Patterns | 2-tier | ✅ Done |
| Phase 4 | list, dropdown, select, select-multiple, select-ladderized, filter, attribute-filter, table, table-actions, table-pagination, date-calendar-picker, date-picker, date-range-picker, month-year-picker, snackbar | 17 | Patterns | 2-tier | ✅ Done |
| Two-Tier Refactor | primitives/ + patterns/ split, chip, event-cell, table-cell | 3 new | Primitives | 2-tier | ✅ Done |
| 3-Tier Migration | avatar, tooltip, table-cell, banner, snackbar, card, empty-state, date-calendar-picker, date-picker, date-range-picker, month-year-picker, time-picker, audit-trail, chips → molecules/ | 14 moved | Molecules | 3-tier | ✅ Done |
After every successful toge task, review this skill:
Update this file at: ~/.claude/skills/toge-component-builder/SKILL.md
Also sync to: skill/toge-component-builder/SKILL.md (project-local copy)