feat: add feature plugin tests and validation for feature flags
@@ -0,0 +1,10 @@
|
||||
import { defineLayerConfig } from '../../../src/index.ts'
|
||||
|
||||
// Aurora — a dark-themed brand. Like Northwind it inherits the whole shell from `main` and changes
|
||||
// only its logo, theme.css and Landing. It keeps the billing page but turns off the beta accent,
|
||||
// showing that each brand toggles a different subset of features.
|
||||
export default defineLayerConfig({
|
||||
name: 'aurora',
|
||||
extends: ['../main'],
|
||||
features: { betaBanner: false }, // keeps `billing` (inherited true); drops the beta pill
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>Aurora — Cloud Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="28" viewBox="0 0 124 28" fill="none" role="img" aria-label="Aurora">
|
||||
<defs>
|
||||
<linearGradient id="au" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#fb7185"/>
|
||||
<stop offset="1" stop-color="#38bdf8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M3 22 a11 11 0 0 1 22 0" fill="none" stroke="url(#au)" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M7.5 22 a6.5 6.5 0 0 1 13 0" fill="none" stroke="url(#au)" stroke-width="3" stroke-linecap="round" opacity="0.5"/>
|
||||
<circle cx="14" cy="22" r="1.8" fill="#fb7185"/>
|
||||
<text x="32" y="19.5" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="-0.4" fill="#eef1f8">Aurora</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 762 B |
@@ -0,0 +1,14 @@
|
||||
/* Aurora — a dark brand. The same shared header, footer, profile and billing pages render dark
|
||||
purely because every token below flips; not one component is overridden. Rose primary, sky accent. */
|
||||
:root {
|
||||
--c-canvas: #0a0f1f;
|
||||
--c-surface: #121829;
|
||||
--c-ink: #eef1f8;
|
||||
--c-muted: #9aa3bd;
|
||||
--c-line: #232b42;
|
||||
--c-brand: #fb7185;
|
||||
--c-on-brand: #1a1022;
|
||||
--c-accent: #38bdf8;
|
||||
--c-radius: 1rem;
|
||||
--c-font: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Aurora has no bootstrap logic of its own — it reuses the base layer's entry.
|
||||
// `@/main.ts` resolves to *this* file first, but the layered resolver's self-skip
|
||||
// (super() semantics) falls through to the next layer, i.e. main/src/main.ts.
|
||||
import '@/main.ts'
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
// Aurora's landing — its own dark, neon take. Same shared shell around it; only this page, the
|
||||
// logo and the theme tokens are Aurora's.
|
||||
const pillars = [
|
||||
{ kbd: '01', title: 'Realtime by default', body: 'Streams, presence and sync primitives baked into the runtime.' },
|
||||
{ kbd: '02', title: 'Edge native', body: 'Deploy to 30+ regions and serve from the closest one automatically.' },
|
||||
{ kbd: '03', title: 'Zero-config scale', body: 'From first request to a million — no capacity planning, ever.' },
|
||||
]
|
||||
const metrics = ['30+ regions', '4ms cold start', '∞ concurrency', '24/7 support']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero -->
|
||||
<section class="relative overflow-hidden">
|
||||
<div
|
||||
class="pointer-events-none absolute -top-40 left-1/2 h-96 w-[42rem] -translate-x-1/2 rounded-full bg-linear-to-br from-brand to-accent opacity-25 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="mx-auto max-w-3xl px-6 pt-24 pb-12 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" /> Aurora 2.0 is live
|
||||
</span>
|
||||
<h1 class="mt-6 text-balance text-5xl font-semibold tracking-tight text-ink sm:text-6xl">
|
||||
Ship at the speed of
|
||||
<span class="bg-linear-to-br from-brand to-accent bg-clip-text text-transparent">light</span>
|
||||
</h1>
|
||||
<p class="mx-auto mt-5 max-w-xl text-balance text-lg text-muted">
|
||||
Aurora is the realtime cloud for builders who refuse to wait. Push code, watch it go global in seconds.
|
||||
</p>
|
||||
<div class="mt-8 flex items-center justify-center gap-3">
|
||||
<a href="#/billing" class="rounded-full bg-brand px-5 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
|
||||
Launch a project
|
||||
</a>
|
||||
<a href="#/profile" class="rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-semibold text-ink transition hover:opacity-90">
|
||||
View console
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metric marquee -->
|
||||
<div class="mx-auto flex max-w-3xl flex-wrap items-center justify-center gap-x-8 gap-y-2 px-6 pb-16 text-sm text-muted">
|
||||
<span v-for="m in metrics" :key="m" class="flex items-center gap-2">
|
||||
<span class="h-1 w-1 rounded-full bg-accent" /> {{ m }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pillars -->
|
||||
<section class="mx-auto max-w-5xl px-6 pb-24">
|
||||
<div class="grid gap-5 sm:grid-cols-3">
|
||||
<article v-for="p in pillars" :key="p.kbd" class="rounded-card border border-line bg-surface p-6">
|
||||
<span class="font-mono text-sm text-brand">{{ p.kbd }}</span>
|
||||
<h3 class="mt-3 text-lg font-semibold text-ink">{{ p.title }}</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">{{ p.body }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 overflow-hidden rounded-card border border-line bg-linear-to-br from-brand/15 to-accent/10 px-8 py-14 text-center">
|
||||
<h2 class="text-balance text-3xl font-semibold tracking-tight text-ink">Your next deploy is one command away</h2>
|
||||
<a href="#/billing" class="mt-7 inline-block rounded-full bg-brand px-6 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
|
||||
Start building
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.vite-layers/tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildViteConfig } from '../../../src/index.ts'
|
||||
|
||||
export default buildViteConfig(import.meta.dirname)
|
||||
@@ -1,5 +1,8 @@
|
||||
import { defineLayerConfig } from '../../../src/index.ts'
|
||||
|
||||
// Northwind — a reseller brand. It inherits the entire shell (header, footer, profile, router,
|
||||
// Tailwind setup) from `main` and changes only three things: its logo, its theme.css tokens and
|
||||
// its Landing page. It also drops the billing page entirely.
|
||||
export default defineLayerConfig({
|
||||
name: 'brand',
|
||||
extends: ['../main'],
|
||||
|
||||
@@ -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 — brand</title>
|
||||
<title>Northwind — Cloud Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1 +1,11 @@
|
||||
BRAND_LOGO_OVERRIDE
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="148" height="28" viewBox="0 0 148 28" fill="none" role="img" aria-label="Northwind">
|
||||
<defs>
|
||||
<linearGradient id="nw" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#0ea5e9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="12" fill="url(#nw)"/>
|
||||
<path d="M14 6 L17 17 L14 14.5 L11 17 Z" fill="#fff"/>
|
||||
<text x="32" y="19.5" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="-0.4" fill="#0b231b">Northwind</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 19 B After Width: | Height: | Size: 590 B |
@@ -0,0 +1,14 @@
|
||||
/* Northwind — a reseller brand. Light, emerald→sky, with rounder corners. This file is the only
|
||||
styling difference from Acme: it shadows main/src/assets/theme.css through the layer resolver. */
|
||||
:root {
|
||||
--c-canvas: #f6fbf8;
|
||||
--c-surface: #ffffff;
|
||||
--c-ink: #0b231b;
|
||||
--c-muted: #5b7a6e;
|
||||
--c-line: #dcebe3;
|
||||
--c-brand: #10b981;
|
||||
--c-on-brand: #ffffff;
|
||||
--c-accent: #0ea5e9;
|
||||
--c-radius: 1.25rem;
|
||||
--c-font: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const title = 'BRAND_HEADER_OVERRIDE'
|
||||
const p2p = __FEATURES__.p2p
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>{{ title }}</header>
|
||||
<p v-if="p2p">p2p</p>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
// Northwind's landing — overrides main/src/pages/Landing.vue via the layer resolver. Its own copy,
|
||||
// layout and imagery; the surrounding header/footer/profile stay shared with every other brand.
|
||||
const points = [
|
||||
'Bring your own cloud — deploy into your existing accounts.',
|
||||
'Granular roles and SSO included on every tier.',
|
||||
'Usage-based pricing that scales down as well as up.',
|
||||
'Migrate from anywhere with first-party importers.',
|
||||
]
|
||||
const regions = ['us-east', 'eu-west', 'ap-south', 'sa-east']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Split hero -->
|
||||
<section class="mx-auto grid max-w-6xl items-center gap-12 px-6 pt-16 pb-20 lg:grid-cols-2">
|
||||
<div>
|
||||
<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" /> Trusted by 2,400+ teams
|
||||
</span>
|
||||
<h1 class="mt-6 text-balance text-5xl font-semibold tracking-tight text-ink">
|
||||
Infrastructure that feels like home
|
||||
</h1>
|
||||
<p class="mt-5 max-w-lg text-balance text-lg text-muted">
|
||||
Northwind runs your apps where your data already lives. No lock-in, no surprises — just a calmer way to operate.
|
||||
</p>
|
||||
<div class="mt-8 flex items-center gap-3">
|
||||
<a href="#/profile" class="rounded-full bg-brand px-5 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
|
||||
Open dashboard
|
||||
</a>
|
||||
<a href="#/" class="rounded-full border border-line bg-surface px-5 py-2.5 text-sm font-semibold text-ink transition hover:bg-ink/4">
|
||||
Book a demo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product preview card -->
|
||||
<div class="rounded-card border border-line bg-surface p-2 shadow-xl shadow-brand/5">
|
||||
<div class="rounded-[calc(var(--c-radius)-0.5rem)] bg-linear-to-br from-brand to-accent p-6 text-on-brand">
|
||||
<p class="text-sm opacity-80">Cluster health</p>
|
||||
<p class="mt-1 text-3xl font-semibold">All systems nominal</p>
|
||||
<div class="mt-6 grid grid-cols-4 gap-2">
|
||||
<div v-for="r in regions" :key="r" class="rounded-xl bg-on-brand/15 px-2 py-3 text-center backdrop-blur">
|
||||
<p class="text-[11px] uppercase tracking-wide opacity-80">{{ r }}</p>
|
||||
<p class="mt-1 text-sm font-semibold">99.9%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<p class="text-sm text-muted">Last deploy</p>
|
||||
<p class="text-sm font-medium text-ink">2 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Value checklist -->
|
||||
<section class="border-y border-line bg-surface">
|
||||
<div class="mx-auto max-w-6xl px-6 py-16">
|
||||
<h2 class="max-w-xl text-balance text-3xl font-semibold tracking-tight text-ink">Built for teams that own their stack</h2>
|
||||
<ul class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<li v-for="p in points" :key="p" class="flex items-start gap-3 rounded-card border border-line bg-canvas p-4">
|
||||
<span class="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-brand/10 text-brand">
|
||||
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
|
||||
</span>
|
||||
<span class="text-sm text-ink">{{ p }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="mx-auto max-w-6xl px-6 py-20 text-center">
|
||||
<h2 class="text-balance text-3xl font-semibold tracking-tight text-ink">Move in this week</h2>
|
||||
<p class="mx-auto mt-3 max-w-md text-balance text-muted">Import your first workload and we'll match your current bill for 90 days.</p>
|
||||
<a href="#/profile" class="mt-7 inline-block rounded-full bg-brand px-6 py-2.5 text-sm font-semibold text-on-brand shadow-sm transition hover:opacity-90">
|
||||
Get started free
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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">© {{ 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||