@sken-ds/vue
v0.10.0
Published
Vue 3 thin adapter over @sken-ds/primitives (Web Components).
Readme
@sken-ds/vue
Vue 3 thin adapter over @sken-ds/primitives. The adapter is glue: the contract lives in @sken-ds/contracts, the implementation lives in @sken-ds/primitives. This package only translates between the primitive's Web Component API and Vue's component API (props, emits, slots).
Why is this so small?
Every adapter SFC in this package is ~30 lines. The reason is that the component itself is not here — it is in @sken-ds/primitives, as a framework-ready Web Component. The adapter only translates between the Web Component API and Vue's component API:
- Imports the primitive (side-effect: registers the custom element).
- Re-exposes the contract types as Vue prop types.
- Re-emits the primitive's
sken-*CustomEvent as a Vue event. - Forwards the default slot to the primitive.
The translation is hand-written, not generated. Each decision (when to use kebab-case vs camelCase, how to handle v-model, how to map named slots, which defineEmits overload to use) is an opinion that would not survive an autogenerator without AI. A future packages/react/SkenButton.tsx will be the same shape and the same size — same contract, same primitive, same glue pattern, different idiom.
Why a per-framework adapter and not SFCs from scratch
We could write <SkenButton> as a 50-line Vue SFC and ship it in @sken-ds/vue. That is what M8 (now M9 first wave) was originally going to do. We changed the plan in 2026-07-15 after a review confirmed that several Legrand Care products already run on other frameworks and will adopt the DS as consumers.
The cost of two parallel implementations is paid forever; the cost of writing a thin adapter is paid once. Lit 3 (the primitive substrate) is ~5 KB and is already in the transitive deps of every Sken consumer, so the cost of "shipping a runtime" is already on the table.
Progressive Implementation (the architecture behind the adapter)
The adapter pattern is the visible half of a deeper architecture: Progressive Implementation Architecture (PIA). The full rationale lives in docs/architecture/progressive-implementation.md and the principle it expresses lives in docs/PRINCIPLES.md § 9. The short version:
- The platform owns the contract (props, events, slots, ARIA semantics) of every component.
- Each component picks the implementation that earns its place. Today most primitives are Lit 3 Web Components in
@sken-ds/primitives. Tomorrow<sken-date-picker>may be PrimeVue,<sken-rich-editor>may be Tiptap, and<sken-button>will still be Lit because the trade-off says it should. - The consumer sees only the contract. The implementation is a private detail of the package that owns the component today.
- Replacing an implementation is a silent refactor: the consumer's
importdoes not change, the prop names do not change, the event names do not change, the tests do not change.
The only place where the implementation's vocabulary is allowed to leak into the consumer-facing surface is the mapper — one file per component, under packages/vue/src/_internal/, fully tested. The mapper is the boundary between "the library we use today" and "the contract we own forever". A worked example of a mapper is at the bottom of this README.
This is Replacement, not Migration. There is no big-bang event. There is no "we are switching the whole stack on Tuesday". There is a per-component RFC, a private refactor of the mapper, and a release note that says "implementation of <sken-X> changed internally; public API unchanged".
"Never copy the external API" (the most important rule)
A naive "wrap the library" looks like this:
// ❌ BAD — we copy Carbon's vocabulary because we wrap Carbon.
import '@carbon/web-components/es/components/checkbox/index.js'
export const SkenCheckbox = 'cds-checkbox' // ← dishonestThis is vendor lock-in with extra steps. The day we want to replace Carbon, the consumer's code (and ours) is full of Carbon's cds-* naming, its cds-checkbox-selected events, its attribute defaults. The wrapper added nothing.
The discipline is the inverse. We design our own contract first, then we map it to whichever implementation we picked today. For Carbon, the primitive is a Lit Web Component in @sken-ds/primitives that owns the contract; the Vue adapter is a thin SFC that translates Vue's prop/event surface to the primitive's HTMLElement surface:
<!-- ✅ GOOD — Vue adapter for the Sken primitive.
The SFC imports the Lit primitive (side-effect: registers
the custom element) and translates Vue props/emits to the
Sken-shaped CustomEvent the primitive emits. -->
<script setup lang="ts">
import '@sken-ds/primitives/sken-checkbox'
import type { SkenCheckboxProps } from '@sken-ds/contracts'
const props = defineProps<SkenCheckboxProps>()
const emit = defineEmits<{
(e: 'change', checked: boolean): void
}>()
function onChange(event: Event) {
const detail = (event as CustomEvent<{ checked: boolean }>).detail
emit('change', detail.checked)
}
</script>
<template>
<sken-checkbox
:checked="props.checked"
:indeterminate="props.indeterminate"
:disabled="props.disabled"
@sken-change="onChange"
>
<slot />
</sken-checkbox>
</template>The adapter is a Vue-shaped wrapper around the Sken primitive which is itself a Lit wrapper around Carbon. The vocabulary Sken consumers see is sken-checkbox and sken-change; the Carbon vocabulary is the adapter's private concern. The day Carbon is replaced, only the Lit primitive changes; the Vue adapter and the contract stay where they are. the consumers' code, the Storybook stories — all of it stays the same.
A non-trivial example: mapping skenVariant (our vocabulary) to Carbon's kind (Carbon's vocabulary):
// packages/primitives/src/components/_internal/carbon/buttonVariants.ts
import type { SkenButtonVariant } from '@sken-ds/contracts'
const carbonButtonKinds = {
primary: 'primary',
secondary: 'secondary',
tertiary: 'tertiary',
quaternary: 'ghost',
} as const satisfies Record<SkenButtonVariant, 'primary' | 'secondary' | 'tertiary' | 'ghost'>
export function mapButtonVariant(skenVariant: SkenButtonVariant) {
return carbonButtonKinds[skenVariant]
}That is the kind of file the platform team owns. It is private to @sken-ds/primitives (the wrapping layer, not the framework adapter), it is tested, and it is the only place where external-API vocabulary is allowed to leak in. If we ever swap implementations, this file is the first thing that changes — and the last. Everything else is the contract.
The full worked example — including the "why object-constant over switch" rationale (one source of truth, compile-time exhaustiveness via as const satisfies, hashmap at runtime) and the naming convention (mapButtonVariant per component, never generic mapVariant) — lives in docs/architecture/progressive-implementation.md. The example above is a short pointer; the doc is the canonical reference.
Layer split
| Layer | Package | Imports |
| --- | --- | --- |
| Contract (types only) | @sken-ds/contracts | nothing |
| Implementation | @sken-ds/primitives | @sken-ds/contracts, lit |
| Vue adapter | @sken-ds/vue (this) | @sken-ds/contracts, @sken-ds/primitives, vue |
A contract change is a TypeScript error in every adapter at the same time. A primitive change ships to every framework at the same time. An adapter change is per-framework and is small.
"Framework-ready", not "framework-agnostic"
@sken-ds/primitives is framework-agnostic at the core (it is a W3C Custom Element, no framework dep beyond Lit). The package @sken-ds/vue is Vue-specific, and consumers import from it. We call the design "framework-ready" because the core ships ready to be wrapped by any framework — but the wrapping itself is intentional, not automatic.
A consumer on React opens packages/react/SkenButton.tsx (when we add it) and writes the same ~30 lines of glue. The component behavior is identical; only the API translation changes.
Adding a new component
- Define the contract in
@sken-ds/contracts/src/index.ts:Props,Slots,Emits,*Aria. - Implement the primitive in
@sken-ds/primitives/src/components/<name>.ts(Lit 3 class extendingLitElement, custom element tagsken-<name>, shadow DOM with token-driven styles, framework-portablesken-click(or component-specific) custom events). - Re-export the primitive from
@sken-ds/primitives/src/index.tsand add a subpath entry in itspackage.json. - Write a thin adapter SFC in this package:
@sken-ds/vue/src/<ComponentName>.vue(~30 lines). - Re-export the adapter from
@sken-ds/vue/src/index.ts. - Write contract tests in
@sken-ds/primitives/test/<name>.test.ts(not in the adapter — the adapter has no logic to test).
What this is NOT
- Not a component library. There is no logic here, only translation.
- Not framework-agnostic. It imports from
vue. Consumers using another framework import from the matching@sken-ds/<framework>package, not from here. - Not a Figma replacement. The Figma library is the source of truth for the visual concept vocabulary; the contract types mirror that vocabulary in code.
See docs/adr/0004-vue-as-primary.md for why Vue is the first adapter, and docs/adr/0005-framework-evolution-roadmap.md for the multi-framework plan. The architecture that makes "implementation can be replaced" work is documented in docs/architecture/progressive-implementation.md and is the architectural expression of docs/PRINCIPLES.md § 9.
Required Vite config (custom elements)
<sken-button> and the other primitives are Web Components, not Vue components. Vue 3's template compiler treats every unknown tag as a component lookup, which crashes at runtime with Maximum call stack size exceeded if sken-* is not in the isCustomElement allowlist.
You must add this to your consumer app's vite.config.ts (or nuxt.config.ts for Nuxt):
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('sken-'),
},
},
})The full rationale, a Nuxt example, and a 3-step test to verify the fix is wired correctly live in docs/custom-elements.md. The same config is in this repo at apps/storybook/vite.config.ts — that file is the canonical reference for any Vite + Vue 3 consumer.
