feat: add feature plugin tests and validation for feature flags

This commit is contained in:
2026-06-21 03:14:19 +07:00
parent ecc958c9f0
commit 1ee76faf55
65 changed files with 4992 additions and 415 deletions
+19 -8
View File
@@ -1,18 +1,29 @@
import vue from '@vitejs/plugin-vue'
import tailwind from '@tailwindcss/vite'
import { DevTools } from '@vitejs/devtools'
import { defineLayerConfig } from '../../../src/index.ts'
export default defineLayerConfig({
name: 'main',
features: { billing: true, p2p: true },
// The framework plugin lives in the layer config, not in vite-layers' core.
vite: {
plugins: [vue()],
// `billing` gates a whole page (build-time DCE); `betaBanner` toggles a UI accent and is
// turned off in production via the `$production` env override below.
features: { billing: true, betaBanner: true },
// Framework + CSS plugins live in the layer config, not in vite-layers' core. Brands inherit them.
// The Vite DevTools hub is added in dev only (`DevTools()` is async → a Promise<Plugin[]>, a valid
// plugin entry); vite-layers auto-mounts its Layers / Features / Resolver / Public & TS panels into
// it via `buildViteConfig`. Build output is untouched (the hub is excluded when command is 'build').
vite: ({ command }) => ({
plugins: [vue(), tailwind(), command === 'serve' && DevTools()],
build: { rolldownOptions: { input: { main: '@/main.ts' } } },
},
}),
// Per-layer tsconfig tweaks (merged across the stack, like Nuxt's typescript.tsConfig).
tsConfig: { compilerOptions: { jsx: 'preserve', jsxImportSource: 'vue' } },
// `vite/client` supplies ambient module decls for the CSS side-effect imports (`*.css`) and
// `import.meta.env`. It flows into every brand's generated app tsconfig too.
tsConfig: { compilerOptions: { jsx: 'preserve', jsxImportSource: 'vue', types: ['vite/client'] } },
$production: {
features: { p2p: false },
}
features: { betaBanner: false }, // the beta accent never ships to prod
},
})
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-layers — main</title>
<title>Acme — Cloud Platform</title>
</head>
<body>
<div id="app"></div>
@@ -1 +1,12 @@
SHARED_FAVICON
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<!-- Shared favicon: inherited by every brand (only logo.svg is overridden per brand). -->
<defs>
<linearGradient id="fav" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6366f1"/>
<stop offset="1" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#fav)"/>
<rect x="8" y="8" width="11" height="11" rx="3" fill="#fff" opacity="0.55"/>
<rect x="13" y="13" width="11" height="11" rx="3" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 14 B

After

Width:  |  Height:  |  Size: 588 B

+12 -1
View File
@@ -1 +1,12 @@
MAIN_LOGO_SVG
<svg xmlns="http://www.w3.org/2000/svg" width="112" height="28" viewBox="0 0 112 28" fill="none" role="img" aria-label="Acme">
<defs>
<linearGradient id="acme" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6366f1"/>
<stop offset="1" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect x="2" y="4" width="13" height="13" rx="4" fill="url(#acme)" opacity="0.35"/>
<rect x="5.5" y="7.5" width="13" height="13" rx="4" fill="url(#acme)" opacity="0.65"/>
<rect x="9" y="11" width="13" height="13" rx="4" fill="url(#acme)"/>
<text x="32" y="19.5" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="-0.4" fill="#18181b">Acme</text>
</svg>

Before

Width:  |  Height:  |  Size: 13 B

After

Width:  |  Height:  |  Size: 719 B

+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { useRoute } from '@/router'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
// The whole shell — header, footer, routed page — is shared by every brand. Brands change only
// their logo, their theme.css tokens, and their Landing page.
const { current } = useRoute()
</script>
<template>
<div class="flex min-h-dvh flex-col bg-canvas font-sans text-ink antialiased">
<AppHeader />
<main class="flex-1">
<component :is="current" v-if="current" />
</main>
<AppFooter />
</div>
</template>
@@ -0,0 +1,15 @@
/* Acme — the base brand. Light, indigo→violet. Every other brand ships its own copy of this
file at the same path; the layer resolver picks the highest-priority one, recoloring the
shared header, footer, profile and billing pages without touching a single component. */
:root {
--c-canvas: #fbfbfd;
--c-surface: #ffffff;
--c-ink: #18181b;
--c-muted: #6b7280;
--c-line: #ececf1;
--c-brand: #6366f1;
--c-on-brand: #ffffff;
--c-accent: #a855f7;
--c-radius: 0.875rem;
--c-font: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@@ -0,0 +1,49 @@
<script setup lang="ts">
// Shared by every brand — identical structure, only recolored by the active theme.css.
const columns = [
{ title: 'Product', links: ['Overview', 'Features', 'Pricing', 'Changelog'] },
{ title: 'Company', links: ['About', 'Careers', 'Blog', 'Contact'] },
{ title: 'Resources', links: ['Docs', 'Guides', 'API', 'Status'] },
{ title: 'Legal', links: ['Privacy', 'Terms', 'Security', 'Cookies'] },
]
const year = new Date().getFullYear()
// Layered public/ asset — bind so it stays a runtime URL (see AppHeader for the why).
const logo = '/logo.svg'
</script>
<template>
<footer class="border-t border-line bg-surface">
<div class="mx-auto max-w-6xl px-6 py-14">
<div class="grid gap-10 md:grid-cols-[1.4fr_repeat(4,1fr)]">
<div class="max-w-xs">
<img :src="logo" alt="Logo" class="h-7 w-auto" />
<p class="mt-4 text-sm leading-relaxed text-muted">
The platform layer your whole organization builds on. Ship faster, on infrastructure you trust.
</p>
</div>
<div v-for="col in columns" :key="col.title">
<h3 class="text-sm font-semibold text-ink">{{ col.title }}</h3>
<ul class="mt-3 space-y-2.5">
<li v-for="link in col.links" :key="link">
<a href="#/" class="text-sm text-muted transition-colors hover:text-ink">{{ link }}</a>
</li>
</ul>
</div>
</div>
<div class="mt-12 flex flex-col items-center justify-between gap-4 border-t border-line pt-6 sm:flex-row">
<p class="text-sm text-muted">&copy; {{ year }} built on vite-layers.</p>
<div class="flex items-center gap-2">
<span
v-for="dot in 3"
:key="dot"
class="h-8 w-8 rounded-full border border-line bg-canvas"
aria-hidden="true"
/>
</div>
</div>
</div>
</footer>
</template>
@@ -1,7 +1,57 @@
<script setup lang="ts">
const title = 'MAIN_HEADER'
import { feature } from '#feature'
import { routes, useRoute } from '@/router'
// One header for every brand. The only thing that differs per brand is `/logo.svg` (a layered
// public/ asset) and the theme tokens behind the utility classes.
const { currentPath } = useRoute()
// Read the build-time flag in <script> (a compiled `_ctx.feature` in a template is not a macro
// call); use the local in the template. Folds to a literal, so the pill is DCE'd out in production.
const beta = feature('betaBanner')
// `/logo.svg` is a layered public/ asset served at runtime — bind it (not a static `src`) so the
// Vue compiler keeps it a URL instead of trying to resolve it as a module at build time.
const logo = '/logo.svg'
</script>
<template>
<header>{{ title }}</header>
<header class="sticky top-0 z-20 border-b border-line bg-surface/80 backdrop-blur">
<div class="mx-auto flex h-16 max-w-6xl items-center gap-5 px-6">
<a href="#/" class="flex shrink-0 items-center gap-2">
<img :src="logo" alt="Logo" class="h-7 w-auto" />
</a>
<span
v-if="beta"
class="hidden rounded-full border border-brand/30 bg-brand/10 px-2 py-0.5 text-xs font-semibold tracking-wide text-brand sm:inline"
>
BETA
</span>
<nav class="ml-2 hidden items-center gap-1 md:flex">
<a
v-for="r in routes"
:key="r.path"
:href="`#${r.path}`"
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
:class="currentPath === r.path ? 'bg-ink/6 text-ink' : 'text-muted hover:text-ink'"
>
{{ r.label }}
</a>
</nav>
<div class="ml-auto flex items-center gap-3">
<a href="#/profile" class="hidden text-sm font-medium text-muted transition-colors hover:text-ink sm:block">
Sign in
</a>
<a
href="#/"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90"
>
Get started
</a>
</div>
</div>
</header>
</template>
+8 -25
View File
@@ -1,27 +1,10 @@
import { createApp, defineAsyncComponent, h, shallowRef, type Component } from 'vue'
const AppHeader = defineAsyncComponent(() => import('@/components/AppHeader.vue'))
import { createApp } from 'vue'
import App from '@/App.vue'
// `__FEATURES__` is typed by the generated `.vite-layers/features.d.ts` — no manual `declare` needed.
// `@/style.css` (Tailwind entry) lives only in the base layer, so every brand shares it.
// `@/theme.css` is layer-resolved: each brand ships its own token file that shadows the base's,
// recoloring the whole shared UI. Imported after style.css so its :root vars win the cascade.
import '@/style.css'
import '@/assets/theme.css'
// Pages are gated on build-time feature flags: a disabled page's dynamic import() is
// statically dead, so its chunk is never emitted (per-brand dead-code elimination).
const routes = [
{ path: '/', component: () => import('@/pages/Home.vue') },
...(__FEATURES__.billing
? [{ path: '/billing', component: () => import('@/pages/Billing.vue') }]
: []),
]
// A tiny hash router so `routes` (and thus the gated import) is actually reachable.
const current = shallowRef<Component | null>(null)
async function navigate() {
const path = location.hash.slice(1) || '/'
const route = routes.find(r => r.path === path) ?? routes[0]
current.value = route ? ((await route.component()).default as Component) : null
}
window.addEventListener('hashchange', navigate)
void navigate()
createApp({
render: () => h('div', [h(AppHeader), current.value ? h(current.value) : null]),
}).mount('#app')
createApp(App).mount('#app')
@@ -1,3 +1,71 @@
<script setup lang="ts">
// BILLING_PAGE_HEAVY_MARKER — grep the build output for this string: it appears in Acme's and
// Aurora's bundles (billing: true) but NOT in Northwind's, whose `billing: false` makes the gated
// import() in router.ts statically dead, so this whole chunk is dead-code-eliminated.
const invoices = [
{ id: 'INV-2048', date: 'Jun 01, 2026', amount: '$240.00', status: 'Paid' },
{ id: 'INV-2031', date: 'May 01, 2026', amount: '$240.00', status: 'Paid' },
{ id: 'INV-2014', date: 'Apr 01, 2026', amount: '$180.00', status: 'Paid' },
]
const usage = [
{ label: 'Seats', used: 18, total: 25 },
{ label: 'Projects', used: 42, total: 50 },
{ label: 'Storage', used: 312, total: 500, unit: 'GB' },
]
</script>
<template>
<main>BILLING_PAGE_HEAVY_MARKER</main>
<div class="mx-auto max-w-3xl px-6 py-12">
<h1 class="text-2xl font-semibold tracking-tight text-ink">Billing</h1>
<p class="mt-1 text-sm text-muted">Manage your plan, usage, and invoices.</p>
<section class="mt-8 overflow-hidden rounded-card border border-line bg-surface">
<div class="flex items-center justify-between gap-4 bg-linear-to-br from-brand to-accent p-6 text-on-brand">
<div>
<p class="text-sm/none opacity-80">Current plan</p>
<p class="mt-1 text-2xl font-semibold">Team $240/mo</p>
</div>
<button class="rounded-full bg-on-brand/15 px-4 py-2 text-sm font-semibold backdrop-blur transition hover:bg-on-brand/25">
Upgrade
</button>
</div>
<div class="grid gap-6 p-6 sm:grid-cols-3">
<div v-for="u in usage" :key="u.label">
<div class="flex items-baseline justify-between">
<span class="text-sm font-medium text-ink">{{ u.label }}</span>
<span class="text-xs text-muted">{{ u.used }}{{ u.unit ? '' : '' }} / {{ u.total }} {{ u.unit }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-ink/10">
<div class="h-full rounded-full bg-brand" :style="{ width: `${(u.used / u.total) * 100}%` }" />
</div>
</div>
</div>
</section>
<section class="mt-6 rounded-card border border-line bg-surface">
<h2 class="border-b border-line px-6 py-4 text-base font-semibold text-ink">Invoices</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-left text-muted">
<th class="px-6 py-3 font-medium">Invoice</th>
<th class="px-6 py-3 font-medium">Date</th>
<th class="px-6 py-3 font-medium">Amount</th>
<th class="px-6 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
<tr v-for="inv in invoices" :key="inv.id">
<td class="px-6 py-3 font-medium text-ink">{{ inv.id }}</td>
<td class="px-6 py-3 text-muted">{{ inv.date }}</td>
<td class="px-6 py-3 text-ink">{{ inv.amount }}</td>
<td class="px-6 py-3">
<span class="rounded-full bg-brand/10 px-2.5 py-0.5 text-xs font-semibold text-brand">{{ inv.status }}</span>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
@@ -0,0 +1,84 @@
<script setup lang="ts">
// Acme's landing — the base version. Each brand ships its own Landing.vue at this path; the layer
// resolver picks the highest-priority one, so the marketing page is fully brand-owned while the
// header, footer and profile around it stay shared.
const features = [
{ title: 'Layered by design', body: 'Compose every surface from shared layers. Override only what a brand needs.' },
{ title: 'Ship in minutes', body: 'Push to production on infrastructure that scales from prototype to fleet.' },
{ title: 'Observability built in', body: 'Traces, logs and metrics on by default — no agents to wire up.' },
{ title: 'Secure by default', body: 'SSO, audit logs and fine-grained roles included in every plan.' },
]
const stats = [
{ value: '99.99%', label: 'Uptime SLA' },
{ value: '120ms', label: 'p95 latency' },
{ value: '8,000+', label: 'Teams' },
]
</script>
<template>
<div>
<!-- Hero -->
<section class="relative overflow-hidden">
<div
class="pointer-events-none absolute inset-x-0 -top-32 mx-auto h-72 max-w-4xl rounded-full bg-linear-to-br from-brand to-accent opacity-20 blur-3xl"
aria-hidden="true"
/>
<div class="mx-auto max-w-3xl px-6 pt-20 pb-16 text-center">
<span class="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-xs font-medium text-muted">
<span class="h-1.5 w-1.5 rounded-full bg-brand" /> Now with edge deployments
</span>
<h1 class="mt-6 text-balance text-5xl font-semibold tracking-tight text-ink sm:text-6xl">
The cloud platform your team grows into
</h1>
<p class="mx-auto mt-5 max-w-xl text-balance text-lg text-muted">
Acme gives you the building blocks to launch, scale and operate modern apps without stitching together a dozen tools.
</p>
<div class="mt-8 flex items-center justify-center gap-3">
<a href="#/" class="rounded-full bg-brand px-5 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
Start for free
</a>
<a href="#/billing" class="rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-semibold text-ink transition hover:bg-ink/4">
See pricing
</a>
</div>
</div>
</section>
<!-- Stats -->
<section class="mx-auto max-w-5xl px-6">
<div class="grid grid-cols-3 gap-4 rounded-card border border-line bg-surface p-8">
<div v-for="s in stats" :key="s.label" class="text-center">
<p class="text-3xl font-semibold tracking-tight text-ink">{{ s.value }}</p>
<p class="mt-1 text-sm text-muted">{{ s.label }}</p>
</div>
</div>
</section>
<!-- Features -->
<section class="mx-auto max-w-5xl px-6 py-20">
<h2 class="max-w-2xl text-balance text-3xl font-semibold tracking-tight text-ink">
Everything you need, nothing you don't
</h2>
<div class="mt-10 grid gap-5 sm:grid-cols-2">
<article v-for="f in features" :key="f.title" class="rounded-card border border-line bg-surface p-6">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-brand/10 text-brand">
<span class="h-2.5 w-2.5 rounded-full bg-brand" />
</div>
<h3 class="mt-4 text-lg font-semibold text-ink">{{ f.title }}</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">{{ f.body }}</p>
</article>
</div>
</section>
<!-- CTA band -->
<section class="mx-auto max-w-5xl px-6 pb-24">
<div class="overflow-hidden rounded-card bg-linear-to-br from-brand to-accent px-8 py-14 text-center text-on-brand">
<h2 class="text-balance text-3xl font-semibold tracking-tight">Ready to build on Acme?</h2>
<p class="mx-auto mt-3 max-w-md text-balance opacity-90">Spin up your first project in under five minutes. No card required.</p>
<a href="#/profile" class="mt-7 inline-block rounded-full bg-on-brand px-6 py-2.5 text-sm font-semibold text-brand transition hover:opacity-90">
Create your workspace
</a>
</div>
</section>
</div>
</template>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { reactive } from 'vue'
// This page is identical for every brand — it lives only in the base layer and is inherited as-is.
// It still adopts each brand's colors and corner radius because it's styled entirely with theme
// tokens, so the same component renders light on Acme/Northwind and dark on Aurora.
const form = reactive({
name: 'Robin Avery',
email: 'robin@example.com',
timezone: 'Europe/Berlin',
role: 'Owner',
})
const timezones = ['Europe/Berlin', 'Europe/London', 'America/New_York', 'Asia/Tokyo']
const notifications = reactive([
{ id: 'product', label: 'Product updates', desc: 'New features and improvements.', on: true },
{ id: 'security', label: 'Security alerts', desc: 'Sign-ins and credential changes.', on: true },
{ id: 'billing', label: 'Billing receipts', desc: 'Invoices and payment notices.', on: false },
])
const initials = 'RA'
</script>
<template>
<div class="mx-auto max-w-3xl px-6 py-12">
<header class="flex items-center gap-4">
<div
class="grid h-16 w-16 place-items-center rounded-card bg-linear-to-br from-brand to-accent text-xl font-semibold text-on-brand"
>
{{ initials }}
</div>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ form.name }}</h1>
<p class="text-sm text-muted">{{ form.email }}</p>
</div>
<span
class="ml-auto rounded-full border border-brand/30 bg-brand/10 px-3 py-1 text-xs font-semibold text-brand"
>
{{ form.role }}
</span>
</header>
<section class="mt-8 rounded-card border border-line bg-surface p-6">
<h2 class="text-base font-semibold text-ink">Account details</h2>
<p class="mt-1 text-sm text-muted">Update your personal information and workspace defaults.</p>
<div class="mt-6 grid gap-5 sm:grid-cols-2">
<label class="block">
<span class="text-sm font-medium text-ink">Display name</span>
<input
v-model="form.name"
type="text"
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
/>
</label>
<label class="block">
<span class="text-sm font-medium text-ink">Email</span>
<input
v-model="form.email"
type="email"
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
/>
</label>
<label class="block sm:col-span-2">
<span class="text-sm font-medium text-ink">Timezone</span>
<select
v-model="form.timezone"
class="mt-1.5 w-full rounded-xl border border-line bg-canvas px-3 py-2 text-sm text-ink outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30"
>
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
</select>
</label>
</div>
</section>
<section class="mt-6 rounded-card border border-line bg-surface p-6">
<h2 class="text-base font-semibold text-ink">Notifications</h2>
<ul class="mt-4 divide-y divide-line">
<li v-for="n in notifications" :key="n.id" class="flex items-center justify-between gap-4 py-4">
<div>
<p class="text-sm font-medium text-ink">{{ n.label }}</p>
<p class="text-sm text-muted">{{ n.desc }}</p>
</div>
<button
type="button"
role="switch"
:aria-checked="n.on"
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
:class="n.on ? 'bg-brand' : 'bg-ink/15'"
@click="n.on = !n.on"
>
<span
class="absolute top-0.5 h-5 w-5 rounded-full bg-surface shadow transition-all"
:class="n.on ? 'left-5.5' : 'left-0.5'"
/>
</button>
</li>
</ul>
</section>
<div class="mt-6 flex items-center justify-end gap-3">
<button type="button" class="rounded-full px-4 py-2 text-sm font-medium text-muted transition hover:text-ink">
Cancel
</button>
<button
type="button"
class="rounded-full bg-brand px-5 py-2 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90"
>
Save changes
</button>
</div>
</div>
</template>
@@ -0,0 +1,41 @@
import { markRaw, shallowRef, type Component } from 'vue'
import { feature } from '#feature'
export interface AppRoute {
path: string
label: string
component: () => Promise<{ default: Component }>
}
// Pages gated on build-time feature flags via the `feature()` macro (typed by the generated
// `.vite-layers/features.d.ts`). A disabled page's dynamic import() is statically dead — its branch
// folds away and the chunk is never emitted (per-brand dead-code elimination), in dev and build alike.
export const routes: AppRoute[] = [
{ path: '/', label: 'Overview', component: () => import('@/pages/Landing.vue') },
{ path: '/profile', label: 'Profile', component: () => import('@/pages/Profile.vue') },
...(feature('billing')
? [{ path: '/billing', label: 'Billing', component: () => import('@/pages/Billing.vue') }]
: []),
]
const current = shallowRef<Component | null>(null)
const currentPath = shallowRef('/')
async function navigate() {
const path = location.hash.slice(1) || '/'
const route = routes.find(r => r.path === path) ?? routes[0]!
currentPath.value = route.path
current.value = markRaw((await route.component()).default)
}
let started = false
/** Minimal hash router shared by every brand — keeps the demo dependency-free. */
export function useRoute() {
if (!started) {
started = true
window.addEventListener('hashchange', navigate)
void navigate()
}
return { current, currentPath }
}
@@ -0,0 +1,23 @@
@import "tailwindcss" source(none);
/* Scan every layer's source for class names. `@source` is relative to this file, which always
resolves to apps/main/src/style.css (it lives only in the base layer), so `../../` is the
apps/ directory — i.e. main + every brand. CWD-independent, unlike Tailwind's auto-detection. */
@source "../../";
/* Map Tailwind's design tokens onto runtime CSS variables. `inline` makes each utility reference
the variable directly — e.g. `bg-brand` becomes `background-color: var(--c-brand)` — so a brand's
theme.css (loaded after this file through the layer resolver) restyles the entire shared UI with
no change to a single component. The --c-* values are defined per brand in `@/theme.css`. */
@theme inline {
--color-canvas: var(--c-canvas);
--color-surface: var(--c-surface);
--color-ink: var(--c-ink);
--color-muted: var(--c-muted);
--color-line: var(--c-line);
--color-brand: var(--c-brand);
--color-on-brand: var(--c-on-brand);
--color-accent: var(--c-accent);
--radius-card: var(--c-radius);
--font-sans: var(--c-font);
}