chore: initial commit

Vue 3 + Tailwind v4 frontend scaffold with living design system
at /design. Pinned dependencies, dev-only a11y toolbar with
colour-vision simulation, WCAG contrast checker, and axe-core audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-19 15:09:27 +01:00
commit 7bd8e0a181
43 changed files with 3296 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Copy to .env.local and fill in. Never commit secrets.
# Base URL of the backend API (Python). Leave empty to use Vite proxy.
VITE_API_BASE_URL=
# Set to "false" to hit the real backend instead of mock fixtures.
VITE_USE_MOCKS=true

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

28
README.md Normal file
View File

@@ -0,0 +1,28 @@
# Kaiser Natron
Ecommerce frontend. Vue 3 + Vite + Tailwind v4. Backend (Python/MySQL) is plugged in at the `src/api/` boundary.
## Setup
```
npm ci
npm run dev
```
## Design system
Everything visual lives in `src/design-system/`:
- `tokens/` — color, typography, spacing, radius (CSS custom properties consumed by Tailwind v4's `@theme`)
- `primitives/` — atomic components (Button, Input, Badge, Stack)
- `patterns/` — composed components (ProductCard, etc.)
Browse the full system at `/design` when running `npm run dev`. This is the single source of truth — new UI composes these, never one-off styling.
## API boundary
`src/api/` exposes a typed surface the backend dev fills in. Until then, fixtures in `src/api/fixtures/` drive the UI so frontend work is unblocked.
## Supply chain
All dep versions are pinned exactly (no `^`/`~`). Use `npm ci` (not `npm install`) in CI and before builds. Run `npm audit` before adding any new dep.

20
index.html Normal file
View File

@@ -0,0 +1,20 @@
<!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" />
<meta name="theme-color" content="#faf7f1" />
<title>Kaiser Natron</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,200;0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,200;1,9..144,400;1,9..144,600&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

13
jsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}

1643
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "kaiser-natron",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"audit": "npm audit --omit=dev"
},
"dependencies": {
"pinia": "3.0.4",
"vue": "3.5.32",
"vue-router": "4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "4.2.2",
"@vitejs/plugin-vue": "6.0.6",
"axe-core": "4.11.3",
"tailwindcss": "4.2.2",
"vite": "8.0.8"
}
}

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0b0b0c"/><text x="16" y="21" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#f5a524" text-anchor="middle">K</text></svg>

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

21
src/App.vue Normal file
View File

@@ -0,0 +1,21 @@
<script setup>
import { computed, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import DefaultLayout from './layouts/DefaultLayout.vue'
const route = useRoute()
const useDefaultLayout = computed(() => route.meta.layout !== 'none')
const isDev = import.meta.env.DEV
const A11yToolbar = isDev
? defineAsyncComponent(() => import('./design-system/devtools/A11yToolbar.vue'))
: null
</script>
<template>
<DefaultLayout v-if="useDefaultLayout">
<router-view />
</DefaultLayout>
<router-view v-else />
<A11yToolbar v-if="isDev" />
</template>

39
src/assets/styles.css Normal file
View File

@@ -0,0 +1,39 @@
@import 'tailwindcss';
@import '../design-system/tokens.css';
html {
scroll-behavior: smooth;
font-size: 17px;
}
body {
background: var(--color-surface);
color: var(--color-ink);
font-family: var(--font-sans);
line-height: 1.55;
}
:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
border-radius: 4px;
}
/* Room above anchor targets when scrolling */
[id] {
scroll-margin-top: 2rem;
}
/* Typography helpers */
.font-display {
font-family: var(--font-serif);
}
.eyebrow {
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--color-muted);
}

View File

@@ -0,0 +1,33 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'neutral',
validator: (v) => ['neutral', 'brand', 'accent', 'subtle', 'success', 'warning', 'danger'].includes(v),
},
uppercase: { type: Boolean, default: true },
})
const variants = {
neutral: 'bg-[var(--color-cream)] text-[var(--color-muted)] border border-[var(--color-line)]',
brand: 'bg-[var(--color-brand)] text-[var(--color-accent)]',
accent: 'bg-[var(--color-accent)] text-[var(--color-accent-ink)]',
subtle: 'bg-[rgba(61,122,85,0.08)] text-[var(--color-brand-soft)]',
success: 'bg-[rgba(61,122,85,0.12)] text-[var(--color-success)]',
warning: 'bg-[rgba(198,144,15,0.15)] text-[var(--color-warning)]',
danger: 'bg-[rgba(178,58,42,0.12)] text-[var(--color-danger)]',
}
const classes = computed(() => [
'inline-flex items-center gap-1 px-[11px] py-[5px] rounded-[var(--radius-pill)] text-[11px] font-bold',
'tracking-[var(--tracking-eyebrow)]',
props.uppercase ? 'uppercase' : '',
variants[props.variant],
])
</script>
<template>
<span :class="classes"><slot /></span>
</template>

View File

@@ -0,0 +1,62 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'primary',
validator: (v) => ['primary', 'secondary', 'ghost', 'danger'].includes(v),
},
size: {
type: String,
default: 'md',
validator: (v) => ['sm', 'md', 'lg'].includes(v),
},
type: { type: String, default: 'button' },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
block: { type: Boolean, default: false },
})
defineEmits(['click'])
const base =
'inline-flex items-center justify-center gap-2 font-sans font-semibold ' +
'rounded-[var(--radius-pill)] border transition-all duration-[var(--duration-base)] ease-[var(--ease-out)] ' +
'disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-brand)]'
const variants = {
primary:
'bg-[var(--color-brand)] text-[var(--color-accent)] border-[var(--color-brand)] ' +
'hover:bg-[var(--color-brand-hover)] hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(28,58,40,0.22)]',
secondary:
'bg-transparent text-[var(--color-brand)] border-[var(--color-brand)] ' +
'hover:bg-[var(--color-brand)] hover:text-[var(--color-accent)]',
ghost:
'bg-transparent text-[var(--color-brand)] border-transparent ' +
'hover:bg-[rgba(28,58,40,0.06)]',
danger:
'bg-[var(--color-danger)] text-white border-[var(--color-danger)] hover:opacity-90',
}
const sizes = {
sm: 'text-[13px] px-[18px] py-[9px] tracking-[var(--tracking-label)]',
md: 'text-[15px] px-[26px] py-[13px] tracking-[var(--tracking-label)]',
lg: 'text-[16px] px-[34px] py-[17px] tracking-[var(--tracking-label)]',
}
const classes = computed(() => [
base,
variants[props.variant],
sizes[props.size],
props.block ? 'w-full' : '',
])
</script>
<template>
<button :type="type" :disabled="disabled || loading" :class="classes" @click="$emit('click', $event)">
<span v-if="loading" class="inline-block h-3 w-3 rounded-full border-2 border-current border-t-transparent animate-spin" />
<slot />
</button>
</template>

View File

@@ -0,0 +1,32 @@
<script setup>
defineProps({
padded: { type: Boolean, default: true },
interactive: { type: Boolean, default: false },
tone: {
type: String,
default: 'paper',
validator: (t) => ['paper', 'cream', 'brand'].includes(t),
},
})
const tones = {
paper: 'bg-[var(--color-paper)] text-[var(--color-ink)] border-[var(--color-line)]',
cream: 'bg-[var(--color-cream)] text-[var(--color-ink)] border-[var(--color-line)]',
brand: 'bg-[var(--color-brand)] text-[var(--color-accent)] border-transparent',
}
</script>
<template>
<div
class="rounded-[var(--radius-md)] border"
:class="[
tones[tone],
padded ? 'p-7' : '',
interactive
? 'transition-all duration-[var(--duration-base)] ease-[var(--ease-out)] hover:-translate-y-1 hover:shadow-[0_16px_44px_rgba(28,58,40,0.10)] hover:border-[var(--color-brand-soft)] cursor-pointer'
: '',
]"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import { computed, useId } from 'vue'
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
label: { type: String, default: '' },
hint: { type: String, default: '' },
error: { type: String, default: '' },
type: { type: String, default: 'text' },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
required: { type: Boolean, default: false },
})
defineEmits(['update:modelValue'])
const uid = useId()
const inputId = computed(() => `in-${uid}`)
const hintId = computed(() => (props.hint ? `hint-${uid}` : undefined))
const errorId = computed(() => (props.error ? `err-${uid}` : undefined))
const describedBy = computed(
() => [hintId.value, errorId.value].filter(Boolean).join(' ') || undefined,
)
</script>
<template>
<div class="flex flex-col gap-2">
<label
v-if="label"
:for="inputId"
class="text-[11px] font-bold uppercase tracking-[var(--tracking-eyebrow)] text-[var(--color-muted)]"
>
{{ label }}<span v-if="required" class="text-[var(--color-danger)]"> *</span>
</label>
<input
:id="inputId"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:aria-invalid="!!error"
:aria-describedby="describedBy"
class="w-full rounded-[var(--radius-sm)] border bg-[var(--color-paper)] px-4 py-3 text-[15px] text-[var(--color-ink)]
placeholder:text-[color:rgba(13,31,19,0.35)]
transition-colors duration-[var(--duration-base)]
focus:outline-none focus:border-[var(--color-brand)]
disabled:opacity-50 disabled:cursor-not-allowed"
:class="error ? 'border-[var(--color-danger)]' : 'border-[var(--color-line)]'"
@input="$emit('update:modelValue', $event.target.value)"
/>
<p v-if="hint && !error" :id="hintId" class="text-[13px] text-[var(--color-muted)]">{{ hint }}</p>
<p v-if="error" :id="errorId" class="text-[13px] text-[var(--color-danger)]">{{ error }}</p>
</div>
</template>

View File

@@ -0,0 +1,330 @@
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
const open = ref(false)
const tab = ref('vision')
const filters = [
{ id: 'none', label: 'Normal vision' },
{ id: 'protanopia', label: 'Protanopia (red-blind)' },
{ id: 'deuteranopia', label: 'Deuteranopia (green-blind)' },
{ id: 'tritanopia', label: 'Tritanopia (blue-blind)' },
{ id: 'achromatopsia', label: 'Achromatopsia (mono)' },
]
const activeFilter = ref('none')
watch(activeFilter, (val) => {
const root = document.documentElement
if (val === 'none') root.style.filter = ''
else root.style.filter = `url(#a11y-${val})`
})
onBeforeUnmount(() => {
document.documentElement.style.filter = ''
})
const fg = ref('#1c3a28')
const bg = ref('#fafafa')
function hexToRgb(hex) {
const h = hex.replace('#', '').trim()
if (h.length !== 3 && h.length !== 6) return null
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
const n = parseInt(full, 16)
if (Number.isNaN(n)) return null
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function relLuminance([r, g, b]) {
const lin = [r, g, b].map((v) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
})
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
}
const contrast = computed(() => {
const a = hexToRgb(fg.value)
const b = hexToRgb(bg.value)
if (!a || !b) return null
const la = relLuminance(a)
const lb = relLuminance(b)
const [hi, lo] = la > lb ? [la, lb] : [lb, la]
return (hi + 0.05) / (lo + 0.05)
})
const contrastLabel = computed(() => {
if (contrast.value == null) return '—'
return contrast.value.toFixed(2) + ':1'
})
const wcag = computed(() => {
const c = contrast.value
if (c == null) return null
return {
aaNormal: c >= 4.5,
aaLarge: c >= 3,
aaaNormal: c >= 7,
aaaLarge: c >= 4.5,
}
})
const audit = ref({ status: 'idle', violations: [], error: null })
async function runAudit() {
audit.value = { status: 'running', violations: [], error: null }
try {
const axe = (await import('axe-core')).default
const results = await axe.run(document, {
resultTypes: ['violations'],
})
audit.value = {
status: 'done',
violations: results.violations,
error: null,
}
if (results.violations.length) {
console.group('%c[a11y] axe violations', 'color:#b84a3b;font-weight:bold')
results.violations.forEach((v) => {
console.log(`${v.impact?.toUpperCase() ?? 'INFO'}${v.id}: ${v.help}`)
console.log(' →', v.helpUrl)
v.nodes.forEach((n) => console.log(' ', n.target.join(' '), n.failureSummary))
})
console.groupEnd()
} else {
console.log('%c[a11y] no violations found', 'color:#1c3a28;font-weight:bold')
}
} catch (e) {
audit.value = { status: 'error', violations: [], error: e.message }
}
}
</script>
<template>
<svg aria-hidden="true" width="0" height="0" style="position:absolute">
<defs>
<filter id="a11y-protanopia">
<feColorMatrix type="matrix" values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0" />
</filter>
<filter id="a11y-deuteranopia">
<feColorMatrix type="matrix" values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0" />
</filter>
<filter id="a11y-tritanopia">
<feColorMatrix type="matrix" values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0" />
</filter>
<filter id="a11y-achromatopsia">
<feColorMatrix type="matrix" values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0" />
</filter>
</defs>
</svg>
<div class="a11y-root" :class="{ 'is-open': open }">
<button
class="a11y-fab"
type="button"
:aria-expanded="open"
aria-label="Accessibility dev tools"
@click="open = !open"
>
<span aria-hidden="true">a11y</span>
<span v-if="activeFilter !== 'none'" class="a11y-dot" aria-hidden="true" />
</button>
<div v-if="open" class="a11y-panel" role="dialog" aria-label="Accessibility tools">
<div class="a11y-tabs">
<button
v-for="t in [
{ id: 'vision', label: 'Vision' },
{ id: 'contrast', label: 'Contrast' },
{ id: 'audit', label: 'Audit' },
]"
:key="t.id"
type="button"
class="a11y-tab"
:class="{ 'is-active': tab === t.id }"
@click="tab = t.id"
>
{{ t.label }}
</button>
</div>
<div v-if="tab === 'vision'" class="a11y-body">
<p class="a11y-hint">Simulate colour vision deficiencies across the whole page.</p>
<label v-for="f in filters" :key="f.id" class="a11y-radio">
<input
type="radio"
name="a11y-filter"
:value="f.id"
v-model="activeFilter"
/>
<span>{{ f.label }}</span>
</label>
</div>
<div v-if="tab === 'contrast'" class="a11y-body">
<p class="a11y-hint">WCAG 2.1 contrast ratio.</p>
<div class="a11y-row">
<label>Foreground
<input type="color" v-model="fg" />
<input type="text" v-model="fg" maxlength="7" />
</label>
</div>
<div class="a11y-row">
<label>Background
<input type="color" v-model="bg" />
<input type="text" v-model="bg" maxlength="7" />
</label>
</div>
<div class="a11y-swatch" :style="{ background: bg, color: fg }">Sample text</div>
<div class="a11y-ratio">{{ contrastLabel }}</div>
<div v-if="wcag" class="a11y-grades">
<span :class="{ pass: wcag.aaNormal, fail: !wcag.aaNormal }">AA normal</span>
<span :class="{ pass: wcag.aaLarge, fail: !wcag.aaLarge }">AA large</span>
<span :class="{ pass: wcag.aaaNormal, fail: !wcag.aaaNormal }">AAA normal</span>
<span :class="{ pass: wcag.aaaLarge, fail: !wcag.aaaLarge }">AAA large</span>
</div>
</div>
<div v-if="tab === 'audit'" class="a11y-body">
<p class="a11y-hint">Run axe-core against the current page. Details logged to console.</p>
<button type="button" class="a11y-btn" @click="runAudit" :disabled="audit.status === 'running'">
{{ audit.status === 'running' ? 'Running…' : 'Run audit' }}
</button>
<div v-if="audit.status === 'done'" class="a11y-audit-result">
<strong>{{ audit.violations.length }}</strong>
violation{{ audit.violations.length === 1 ? '' : 's' }}
<ul v-if="audit.violations.length">
<li v-for="v in audit.violations" :key="v.id">
<span class="a11y-impact" :data-impact="v.impact">{{ v.impact }}</span>
{{ v.id }} — {{ v.help }}
</li>
</ul>
</div>
<div v-else-if="audit.status === 'error'" class="a11y-error">{{ audit.error }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.a11y-root {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 9999;
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 13px;
color: #1c3a28;
}
.a11y-fab {
width: 56px;
height: 56px;
border-radius: 999px;
border: none;
background: #1c3a28;
color: #f0c75e;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow: 0 6px 18px rgba(28, 58, 40, 0.25);
position: relative;
transition: transform 0.15s ease;
}
.a11y-fab:hover { transform: translateY(-1px); }
.a11y-dot {
position: absolute;
top: 8px;
right: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #f0c75e;
box-shadow: 0 0 0 2px #1c3a28;
}
.a11y-panel {
position: absolute;
bottom: 68px;
right: 0;
width: 320px;
max-height: 72vh;
overflow-y: auto;
background: #fff;
border: 1px solid #e2e8df;
border-radius: 14px;
box-shadow: 0 16px 44px rgba(28, 58, 40, 0.18);
padding: 14px;
}
.a11y-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid #e2e8df;
margin-bottom: 12px;
}
.a11y-tab {
flex: 1;
padding: 8px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7a71;
}
.a11y-tab.is-active {
color: #1c3a28;
border-bottom-color: #1c3a28;
}
.a11y-body { display: flex; flex-direction: column; gap: 10px; }
.a11y-hint { margin: 0; color: #6b7a71; font-size: 12px; }
.a11y-radio { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.a11y-row label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #6b7a71;
}
.a11y-row input[type="color"] {
width: 32px; height: 32px; border: 1px solid #e2e8df; border-radius: 6px; padding: 0; background: none; cursor: pointer;
}
.a11y-row input[type="text"] {
flex: 1; font-family: ui-monospace, monospace; font-size: 12px;
border: 1px solid #e2e8df; border-radius: 6px; padding: 6px 8px;
}
.a11y-swatch {
padding: 14px; border-radius: 8px; text-align: center; font-weight: 500;
border: 1px solid #e2e8df;
}
.a11y-ratio {
text-align: center; font-family: ui-monospace, monospace; font-size: 18px; font-weight: 600;
}
.a11y-grades {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
font-size: 11px; font-family: ui-monospace, monospace;
}
.a11y-grades .pass { color: #1c3a28; }
.a11y-grades .pass::before { content: '✓ '; }
.a11y-grades .fail { color: #b84a3b; opacity: 0.6; }
.a11y-grades .fail::before { content: '✗ '; }
.a11y-btn {
background: #1c3a28; color: #f0c75e; border: none;
padding: 10px 16px; border-radius: 999px; cursor: pointer; font-weight: 600;
}
.a11y-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.a11y-audit-result ul {
margin: 8px 0 0; padding-left: 16px; max-height: 240px; overflow-y: auto;
font-size: 12px;
}
.a11y-audit-result li { margin-bottom: 6px; }
.a11y-impact {
display: inline-block; padding: 1px 6px; border-radius: 4px;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
background: #e2e8df; margin-right: 4px;
}
.a11y-impact[data-impact="critical"] { background: #b84a3b; color: #fff; }
.a11y-impact[data-impact="serious"] { background: #d47e3a; color: #fff; }
.a11y-impact[data-impact="moderate"] { background: #f0c75e; color: #1c3a28; }
.a11y-error { color: #b84a3b; font-size: 12px; }
</style>

View File

@@ -0,0 +1,73 @@
/*
* Design tokens — single source of truth.
* Inspired by the Kaiser Natron reference site:
* pine green + warm yellow on cream, Fraunces serif + DM Sans, pill buttons.
* Consumed by Tailwind v4 via @theme and by raw CSS via var(--*).
*/
@theme {
/* ——— Color ——————————————————————————————————————————————— */
/* Brand (pine green) */
--color-brand: #1c3a28;
--color-brand-hover: #2b5540;
--color-brand-soft: #3d7a55;
/* Accent (warm yellow) */
--color-accent: #e9c84b;
--color-accent-soft: #f2dc7c;
--color-accent-ink: #1c3a28;
/* Neutrals — warm, green-tinted */
--color-ink: #0d1f13;
--color-muted: #5a7866;
--color-line: rgba(28, 58, 40, 0.11);
--color-line-strong: rgba(28, 58, 40, 0.22);
--color-cream: #f4efe4;
--color-surface: #faf7f1;
--color-paper: #ffffff;
/* Semantic */
--color-success: #3d7a55;
--color-warning: #c6900f;
--color-danger: #b23a2a;
/* ——— Typography ———————————————————————————————————————————— */
--font-serif: 'Fraunces', ui-serif, Georgia, 'Times New Roman', serif;
--font-sans: 'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-display: clamp(3.25rem, 5.2vw, 5.125rem);
--tracking-eyebrow: 0.08em;
--tracking-label: 0.04em;
--tracking-wide: 0.12em;
--tracking-tight: -0.025em;
/* ——— Radius ———————————————————————————————————————————————— */
--radius-xs: 6px;
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 20px;
--radius-xl: 28px;
--radius-pill: 100px;
/* ——— Shadow (green-tinted) ———————————————————————————————— */
--shadow-sm: 0 4px 24px rgba(28, 58, 40, 0.09);
--shadow-md: 0 12px 28px rgba(28, 58, 40, 0.15);
--shadow-lg: 0 16px 44px rgba(28, 58, 40, 0.18);
/* ——— Motion ——————————————————————————————————————————————— */
--duration-fast: 120ms;
--duration-base: 200ms;
--duration-slow: 320ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}

View File

@@ -0,0 +1,87 @@
<script setup>
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
const isDesign = computed(() => route.name === 'design')
const designSections = [
{ id: 'colors', label: 'Colors' },
{ id: 'typography', label: 'Typography' },
{ id: 'radii', label: 'Radii' },
{ id: 'shadows', label: 'Shadows' },
{ id: 'buttons', label: 'Buttons' },
{ id: 'badges', label: 'Badges' },
{ id: 'inputs', label: 'Inputs' },
{ id: 'cards', label: 'Cards' },
]
</script>
<template>
<div class="min-h-screen flex">
<!-- Sidebar -->
<aside
class="fixed inset-y-0 left-0 w-[260px] border-r border-[var(--color-line)] bg-[var(--color-surface)] flex flex-col z-40"
>
<!-- Logo -->
<div class="h-[78px] shrink-0 flex items-center px-6 border-b border-[var(--color-line)]">
<RouterLink
to="/"
class="font-display text-[20px] font-normal tracking-[-0.01em] text-[var(--color-ink)]"
>
Kaiser<span class="italic font-light text-[var(--color-brand-soft)]"> Natron</span>
</RouterLink>
</div>
<!-- Primary nav -->
<nav class="flex-1 overflow-y-auto p-4 space-y-1">
<RouterLink
to="/"
class="flex items-center gap-3 px-3 py-2 rounded-[var(--radius-sm)] text-[14px] font-medium text-[var(--color-muted)] hover:text-[var(--color-brand)] hover:bg-[rgba(28,58,40,0.06)] transition-colors"
active-class="!text-[var(--color-brand)] !bg-[rgba(28,58,40,0.08)]"
>
<span class="inline-block w-1.5 h-1.5 rounded-full bg-current opacity-60" />
Home
</RouterLink>
<div>
<RouterLink
to="/design"
class="flex items-center gap-3 px-3 py-2 rounded-[var(--radius-sm)] text-[14px] font-medium text-[var(--color-muted)] hover:text-[var(--color-brand)] hover:bg-[rgba(28,58,40,0.06)] transition-colors"
active-class="!text-[var(--color-brand)] !bg-[rgba(28,58,40,0.08)]"
>
<span class="inline-block w-1.5 h-1.5 rounded-full bg-current opacity-60" />
Design system
</RouterLink>
<!-- Nested sub-sections, visible only on /design -->
<div
v-if="isDesign"
class="mt-1 ml-[18px] pl-3 border-l border-[var(--color-line)] flex flex-col"
>
<a
v-for="s in designSections"
:key="s.id"
:href="`#${s.id}`"
class="px-3 py-1.5 rounded-[var(--radius-sm)] text-[13px] font-medium text-[var(--color-muted)] hover:text-[var(--color-brand)] hover:bg-[rgba(28,58,40,0.05)] transition-colors"
>
{{ s.label }}
</a>
</div>
</div>
</nav>
<!-- Sidebar footer -->
<div class="shrink-0 p-4 border-t border-[var(--color-line)]">
<p class="text-[11px] font-bold uppercase tracking-[var(--tracking-eyebrow)] text-[var(--color-muted)]">
Kaiser Natron · v0.1
</p>
</div>
</aside>
<!-- Main content (offset by sidebar width) -->
<main class="flex-1 ml-[260px] min-w-0">
<slot />
</main>
</div>
</template>

10
src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.js'
import './assets/styles.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

259
src/pages/DesignPage.vue Normal file
View File

@@ -0,0 +1,259 @@
<script setup>
import { ref } from 'vue'
import Button from '@/design-system/components/Button.vue'
import Input from '@/design-system/components/Input.vue'
import Badge from '@/design-system/components/Badge.vue'
import Card from '@/design-system/components/Card.vue'
const colorTokens = [
{ group: 'Brand', names: ['brand', 'brand-hover', 'brand-soft'] },
{ group: 'Accent', names: ['accent', 'accent-soft', 'accent-ink'] },
{ group: 'Surface', names: ['surface', 'paper', 'cream'] },
{ group: 'Ink', names: ['ink', 'muted'] },
{ group: 'Line', names: ['line', 'line-strong'] },
{ group: 'Semantic', names: ['success', 'warning', 'danger'] },
]
const radii = [
{ name: 'xs', value: '6px' },
{ name: 'sm', value: '10px' },
{ name: 'md', value: '16px' },
{ name: 'lg', value: '20px' },
{ name: 'xl', value: '28px' },
{ name: 'pill', value: '100px' },
]
const shadows = [
{ name: 'sm', label: 'var(--shadow-sm)' },
{ name: 'md', label: 'var(--shadow-md)' },
{ name: 'lg', label: 'var(--shadow-lg)' },
]
const inputEmail = ref('')
const inputErr = ref('')
</script>
<template>
<div class="bg-[#fafafa] min-h-screen">
<div class="mx-auto max-w-4xl px-10 lg:px-16 py-20">
<!-- Header -->
<header class="mb-24 max-w-3xl">
<p class="eyebrow mb-4">Design system</p>
<h1
class="font-display font-normal tracking-[var(--tracking-tight)] leading-[1.05] text-[var(--color-ink)]"
style="font-size: clamp(2.5rem, 4.5vw, 4rem);"
>
Kaiser Natron
<em class="italic font-light text-[var(--color-brand-soft)]">tokens & components</em>
</h1>
<p class="mt-6 text-[17px] text-[var(--color-muted)] leading-relaxed">
The single source of truth for color, typography, and composition. Every page in the
app is built by composing the primitives below if it doesn't appear here, it doesn't
belong in a page yet.
</p>
</header>
<div class="space-y-32">
<!-- Colors -->
<section id="colors">
<p class="eyebrow mb-3">Tokens</p>
<h2 class="font-display text-4xl font-normal tracking-[var(--tracking-tight)] mb-12">Colors</h2>
<div class="space-y-12">
<div v-for="group in colorTokens" :key="group.group">
<h3 class="text-sm font-semibold text-[var(--color-muted)] mb-5">{{ group.group }}</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div
v-for="name in group.names"
:key="name"
class="rounded-[var(--radius-md)] border border-[var(--color-line)] overflow-hidden bg-[var(--color-paper)]"
>
<div
class="h-24 border-b border-[var(--color-line)]"
:style="{ background: `var(--color-${name})` }"
/>
<div class="px-4 py-3">
<code class="font-mono text-[12px] text-[var(--color-ink)]">--color-{{ name }}</code>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Typography -->
<section id="typography">
<p class="eyebrow mb-3">Tokens</p>
<h2 class="font-display text-4xl font-normal tracking-[var(--tracking-tight)] mb-12">Typography</h2>
<div class="grid md:grid-cols-2 gap-6 mb-16">
<Card tone="paper">
<p class="eyebrow mb-3">Display</p>
<p class="font-display text-5xl font-normal leading-[1.05] mb-3">Fraunces</p>
<p class="text-sm text-[var(--color-muted)]">
Serif with optical sizing. Use for hero, section titles, product names.
</p>
</Card>
<Card tone="paper">
<p class="eyebrow mb-3">Body</p>
<p class="font-sans text-5xl font-medium leading-[1.05] mb-3">DM Sans</p>
<p class="text-sm text-[var(--color-muted)]">
Clean geometric sans. Use for body, UI, navigation, labels.
</p>
</Card>
</div>
<h3 class="eyebrow mb-5">Scale</h3>
<div class="divide-y divide-[var(--color-line)]">
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-xs</code>
<span class="text-xs">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-sm</code>
<span class="text-sm">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-base</code>
<span class="text-base">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-lg</code>
<span class="text-lg">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-xl</code>
<span class="text-xl">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-2xl</code>
<span class="text-2xl">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-3xl</code>
<span class="text-3xl">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-4xl</code>
<span class="text-4xl">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-5xl</code>
<span class="text-5xl">The quick brown fox</span>
</div>
</div>
</section>
<!-- Radii -->
<section id="radii">
<p class="eyebrow mb-3">Tokens</p>
<h2 class="font-display text-4xl font-normal tracking-[var(--tracking-tight)] mb-12">Radii</h2>
<div class="grid grid-cols-3 sm:grid-cols-6 gap-6">
<div v-for="r in radii" :key="r.name" class="text-center">
<div
class="h-20 w-full bg-[var(--color-paper)] border border-[var(--color-line)] mb-3"
:style="{ borderRadius: `var(--radius-${r.name})` }"
/>
<code class="font-mono text-[11px] text-[var(--color-ink)] block">--radius-{{ r.name }}</code>
<span class="text-[11px] text-[var(--color-muted)]">{{ r.value }}</span>
</div>
</div>
</section>
<!-- Shadows -->
<section id="shadows">
<p class="eyebrow mb-3">Tokens</p>
<h2 class="font-display text-4xl font-normal tracking-[var(--tracking-tight)] mb-12">Shadows</h2>
<div class="grid sm:grid-cols-3 gap-6">
<div v-for="s in shadows" :key="s.name">
<div
class="h-28 rounded-[var(--radius-md)] bg-[var(--color-paper)] mb-4"
:style="{ boxShadow: s.label }"
/>
<code class="font-mono text-[11px] text-[var(--color-ink)] block">--shadow-{{ s.name }}</code>
</div>
</div>
</section>
<!-- Buttons -->
<section id="buttons">
<p class="eyebrow mb-3">Components</p>
<h2 class="font-display text-4xl font-normal tracking-[var(--tracking-tight)] mb-12">Buttons</h2>
<div class="space-y-10">
<div>
<h3 class="eyebrow mb-5">Variants</h3>
<div class="flex flex-wrap gap-3">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
</div>
</div>
<div>
<h3 class="eyebrow mb-5">Sizes</h3>
<div class="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
</div>
<div>
<h3 class="eyebrow mb-5">States</h3>
<div class="flex flex-wrap gap-3">
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
<Button block class="max-w-sm">Block</Button>
</div>
</div>
</div>
</section>
<!-- Badges -->
<section id="badges">
<p class="eyebrow mb-3">Components</p>
<h2 class="font-display text-4xl font-normal tracking-[var(--tracking-tight)] mb-12">Badges</h2>
<div class="flex flex-wrap gap-3">
<Badge variant="neutral">Neutral</Badge>
<Badge variant="brand">Brand</Badge>
<Badge variant="accent">Accent</Badge>
<Badge variant="subtle">Subtle</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
</div>
</section>
<!-- Inputs -->
<section id="inputs">
<p class="eyebrow mb-3">Components</p>
<h2 class="font-display text-4xl font-normal tracking-[var(--tracking-tight)] mb-12">Inputs</h2>
<div class="grid md:grid-cols-2 gap-6">
<Input v-model="inputEmail" label="Email" placeholder="you@example.com" hint="We never share this." />
<Input v-model="inputErr" label="Required field" required error="This field is required" />
<Input label="Disabled" placeholder="Can't type here" disabled />
<Input label="Password" type="password" placeholder="••••••••" />
</div>
</section>
<!-- Cards -->
<section id="cards">
<p class="eyebrow mb-3">Components</p>
<h2 class="font-display text-4xl font-normal tracking-[var(--tracking-tight)] mb-12">Cards</h2>
<div class="grid md:grid-cols-3 gap-6">
<Card tone="paper">
<h3 class="font-display text-2xl font-normal mb-2">Paper</h3>
<p class="text-sm text-[var(--color-muted)]">Default surface for most content neutral, high contrast.</p>
</Card>
<Card tone="cream" interactive>
<h3 class="font-display text-2xl font-normal mb-2">Cream · interactive</h3>
<p class="text-sm text-[var(--color-muted)]">Hover lifts and casts a green shadow.</p>
</Card>
<Card tone="brand">
<h3 class="font-display text-2xl font-normal mb-2">Brand</h3>
<p class="text-sm opacity-80">Feature panels and CTAs on dark green.</p>
</Card>
</div>
</section>
</div>
</div>
</div>
</template>

23
src/pages/HomePage.vue Normal file
View File

@@ -0,0 +1,23 @@
<script setup>
import { RouterLink } from 'vue-router'
import Button from '@/design-system/components/Button.vue'
</script>
<template>
<section class="mx-auto max-w-4xl px-6 py-28 text-center">
<p class="eyebrow mb-4">Scaffolding</p>
<h1
class="font-display font-normal tracking-[var(--tracking-tight)] leading-[1.05]"
style="font-size: clamp(3rem, 5vw, 4.5rem);"
>
Design system <em class="italic font-light text-[var(--color-brand-soft)]">first</em>.
</h1>
<p class="mt-5 text-[var(--color-muted)] max-w-xl mx-auto">
Tokens, primitives, and patterns live in <code class="font-mono text-sm">/src/design-system</code>.
Browse the live reference and iterate.
</p>
<RouterLink to="/design" class="inline-block mt-10">
<Button size="lg">Open design system</Button>
</RouterLink>
</section>
</template>

View File

@@ -0,0 +1,39 @@
<script setup>
import SectionShell from './SectionShell.vue'
import Badge from '@/design-system/components/Badge.vue'
import Card from '@/design-system/components/Card.vue'
</script>
<template>
<SectionShell
eyebrow="Components"
title="Badges"
description="Small uppercase labels for metadata, status, and eyebrows above headings."
>
<section>
<h2 class="eyebrow mb-5">Variants</h2>
<Card tone="paper">
<div class="flex flex-wrap gap-3">
<Badge variant="neutral">Neutral</Badge>
<Badge variant="brand">Brand</Badge>
<Badge variant="accent">Accent</Badge>
<Badge variant="subtle">Subtle</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">Non-uppercase</h2>
<Card tone="paper">
<div class="flex flex-wrap gap-3">
<Badge variant="brand" :uppercase="false">New release</Badge>
<Badge variant="accent" :uppercase="false">Featured</Badge>
<Badge variant="subtle" :uppercase="false">v2.1.0</Badge>
</div>
</Card>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import SectionShell from './SectionShell.vue'
import Button from '@/design-system/components/Button.vue'
import Card from '@/design-system/components/Card.vue'
</script>
<template>
<SectionShell
eyebrow="Components"
title="Buttons"
description="Pill-shaped, pine-green primary with a warm-yellow label. Secondary, ghost, and danger variants for supporting actions."
>
<section>
<h2 class="eyebrow mb-5">Variants</h2>
<Card tone="paper">
<div class="flex flex-wrap gap-3">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">Sizes</h2>
<Card tone="paper">
<div class="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">States</h2>
<Card tone="paper">
<div class="flex flex-wrap gap-3 items-center">
<Button>Default</Button>
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">Block</h2>
<Card tone="paper">
<div class="max-w-md">
<Button block variant="primary">Full-width primary</Button>
</div>
</Card>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import SectionShell from './SectionShell.vue'
import Card from '@/design-system/components/Card.vue'
import Badge from '@/design-system/components/Badge.vue'
</script>
<template>
<SectionShell
eyebrow="Components"
title="Cards"
description="Surfaces for grouping content. Three tones — paper, cream, brand — and an optional interactive lift on hover."
>
<section>
<h2 class="eyebrow mb-5">Tones</h2>
<div class="grid md:grid-cols-3 gap-6">
<Card tone="paper">
<Badge variant="subtle" class="mb-4">Paper</Badge>
<h3 class="font-display text-2xl font-normal mb-2">Default surface</h3>
<p class="text-[14px] text-[var(--color-muted)] leading-relaxed">
The go-to card for most content. High contrast on the cream page background.
</p>
</Card>
<Card tone="cream">
<Badge variant="subtle" class="mb-4">Cream</Badge>
<h3 class="font-display text-2xl font-normal mb-2">Warm surface</h3>
<p class="text-[14px] text-[var(--color-muted)] leading-relaxed">
A softer alternative for secondary sections or callouts.
</p>
</Card>
<Card tone="brand">
<Badge variant="accent" class="mb-4">Brand</Badge>
<h3 class="font-display text-2xl font-normal mb-2">Dark surface</h3>
<p class="text-[14px] opacity-80 leading-relaxed">
For feature panels, CTAs, and moments that want to stand out.
</p>
</Card>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">Interactive</h2>
<div class="grid md:grid-cols-2 gap-6">
<Card tone="paper" interactive>
<h3 class="font-display text-2xl font-normal mb-2">Hover me</h3>
<p class="text-[14px] text-[var(--color-muted)] leading-relaxed">
Lifts on hover with a soft green-tinted shadow. Use for clickable items in a grid.
</p>
</Card>
<Card tone="cream" interactive>
<h3 class="font-display text-2xl font-normal mb-2">Hover me too</h3>
<p class="text-[14px] text-[var(--color-muted)] leading-relaxed">
Same behavior on the warm surface.
</p>
</Card>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">Without padding</h2>
<Card tone="paper" :padded="false">
<div class="h-40 bg-[var(--color-cream)] rounded-t-[var(--radius-md)]" />
<div class="p-7">
<h3 class="font-display text-2xl font-normal mb-2">Media card</h3>
<p class="text-[14px] text-[var(--color-muted)] leading-relaxed">
When the card needs a full-bleed image or header, pass <code class="font-mono text-[12px]">:padded="false"</code>.
</p>
</div>
</Card>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,39 @@
<script setup>
import SectionShell from './SectionShell.vue'
const groups = [
{ title: 'Brand', names: ['brand', 'brand-hover', 'brand-soft'] },
{ title: 'Accent', names: ['accent', 'accent-soft', 'accent-ink'] },
{ title: 'Surface', names: ['surface', 'paper', 'cream'] },
{ title: 'Ink', names: ['ink', 'muted'] },
{ title: 'Line', names: ['line', 'line-strong'] },
{ title: 'Semantic', names: ['success', 'warning', 'danger'] },
]
</script>
<template>
<SectionShell
eyebrow="Tokens"
title="Colors"
description="Pine green on cream, with a warm yellow accent. All UI color flows from these tokens — never hand-pick values in components."
>
<section v-for="group in groups" :key="group.title">
<h2 class="eyebrow mb-5">{{ group.title }}</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div
v-for="name in group.names"
:key="name"
class="rounded-[var(--radius-md)] border border-[var(--color-line)] overflow-hidden bg-[var(--color-paper)]"
>
<div
class="h-28 border-b border-[var(--color-line)]"
:style="{ background: `var(--color-${name})` }"
/>
<div class="px-4 py-3">
<code class="font-mono text-[12px] text-[var(--color-ink)] block">--color-{{ name }}</code>
</div>
</div>
</div>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,69 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
const groups = [
{
title: 'Tokens',
items: [
{ name: 'ds-colors', label: 'Colors' },
{ name: 'ds-typography', label: 'Typography' },
{ name: 'ds-radii', label: 'Radii' },
{ name: 'ds-shadows', label: 'Shadows' },
],
},
{
title: 'Components',
items: [
{ name: 'ds-buttons', label: 'Buttons' },
{ name: 'ds-badges', label: 'Badges' },
{ name: 'ds-inputs', label: 'Inputs' },
{ name: 'ds-cards', label: 'Cards' },
],
},
]
</script>
<template>
<div class="h-screen flex bg-[#fafafa] text-[var(--color-ink)] overflow-hidden">
<!-- Sidebar -->
<aside class="w-[260px] shrink-0 border-r border-[var(--color-line)] bg-[var(--color-paper)] flex flex-col">
<div class="px-6 py-6 border-b border-[var(--color-line)]">
<RouterLink to="/" class="font-display text-[20px] leading-tight text-[var(--color-ink)]">
Kaiser<em class="italic font-light text-[var(--color-brand-soft)]"> Natron</em>
</RouterLink>
<p class="eyebrow mt-2">Design system</p>
</div>
<nav class="flex-1 overflow-y-auto px-3 py-5 space-y-6">
<div v-for="group in groups" :key="group.title">
<p class="eyebrow px-3 mb-2">{{ group.title }}</p>
<div class="flex flex-col gap-0.5">
<RouterLink
v-for="item in group.items"
:key="item.name"
:to="{ name: item.name }"
class="px-3 py-2 rounded-[var(--radius-sm)] text-[14px] font-medium text-[var(--color-muted)] hover:text-[var(--color-brand)] hover:bg-[rgba(28,58,40,0.05)] transition-colors"
active-class="!text-[var(--color-brand)] !bg-[rgba(28,58,40,0.08)]"
>
{{ item.label }}
</RouterLink>
</div>
</div>
</nav>
<div class="px-6 py-4 border-t border-[var(--color-line)]">
<RouterLink
to="/"
class="text-[13px] text-[var(--color-muted)] hover:text-[var(--color-brand)] transition-colors"
>
Back to site
</RouterLink>
</div>
</aside>
<!-- Main scrolling content -->
<main class="flex-1 overflow-y-auto">
<RouterView />
</main>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue'
import SectionShell from './SectionShell.vue'
import Input from '@/design-system/components/Input.vue'
import Card from '@/design-system/components/Card.vue'
const email = ref('')
const password = ref('')
const required = ref('')
</script>
<template>
<SectionShell
eyebrow="Components"
title="Inputs"
description="Paper surface with a thin green-tinted border. Uppercase eyebrow labels. Focus deepens the border to brand green."
>
<section>
<h2 class="eyebrow mb-5">Default</h2>
<Card tone="paper">
<div class="grid md:grid-cols-2 gap-6 max-w-3xl">
<Input
v-model="email"
label="Email"
type="email"
placeholder="you@example.com"
hint="We never share this."
/>
<Input
v-model="password"
label="Password"
type="password"
placeholder="••••••••"
/>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">States</h2>
<Card tone="paper">
<div class="grid md:grid-cols-2 gap-6 max-w-3xl">
<Input
v-model="required"
label="Required field"
required
error="This field is required"
/>
<Input
label="Disabled"
placeholder="Can't type here"
disabled
/>
</div>
</Card>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import SectionShell from './SectionShell.vue'
const radii = [
{ name: 'xs', value: '6px' },
{ name: 'sm', value: '10px' },
{ name: 'md', value: '16px' },
{ name: 'lg', value: '20px' },
{ name: 'xl', value: '28px' },
{ name: 'pill', value: '100px' },
]
</script>
<template>
<SectionShell
eyebrow="Tokens"
title="Radii"
description="From subtle 6px rounding on small elements to full pills on buttons. Matches the reference site's soft, organic feel."
>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-6">
<div v-for="r in radii" :key="r.name" class="flex flex-col items-center">
<div
class="h-32 w-full bg-[var(--color-paper)] border border-[var(--color-line)] mb-4 shadow-[var(--shadow-sm)]"
:style="{ borderRadius: `var(--radius-${r.name})` }"
/>
<code class="font-mono text-[12px] text-[var(--color-ink)] block">--radius-{{ r.name }}</code>
<span class="text-[12px] text-[var(--color-muted)] mt-1">{{ r.value }}</span>
</div>
</div>
</SectionShell>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
defineProps({
eyebrow: { type: String, default: 'Design system' },
title: { type: String, required: true },
description: { type: String, default: '' },
})
</script>
<template>
<div class="mx-auto max-w-5xl px-10 lg:px-16 py-16">
<header class="mb-14 max-w-2xl">
<p class="eyebrow mb-3">{{ eyebrow }}</p>
<h1 class="font-display text-5xl font-normal tracking-[var(--tracking-tight)] leading-[1.05] text-[var(--color-ink)]">
{{ title }}
</h1>
<p v-if="description" class="mt-5 text-[17px] text-[var(--color-muted)] leading-relaxed">
{{ description }}
</p>
</header>
<div class="space-y-16">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import SectionShell from './SectionShell.vue'
const shadows = [
{ name: 'sm', css: 'var(--shadow-sm)', note: 'Subtle — nav on scroll, resting cards' },
{ name: 'md', css: 'var(--shadow-md)', note: 'Medium — primary button hover' },
{ name: 'lg', css: 'var(--shadow-lg)', note: 'Large — floating cards, overlays' },
]
</script>
<template>
<SectionShell
eyebrow="Tokens"
title="Shadows"
description="All shadows are tinted green rather than neutral black — they feel warm and part of the palette."
>
<div class="grid sm:grid-cols-3 gap-8">
<div v-for="s in shadows" :key="s.name" class="space-y-4">
<div
class="h-36 rounded-[var(--radius-md)] bg-[var(--color-paper)]"
:style="{ boxShadow: s.css }"
/>
<div>
<code class="font-mono text-[12px] text-[var(--color-ink)] block">--shadow-{{ s.name }}</code>
<p class="text-[13px] text-[var(--color-muted)] mt-1">{{ s.note }}</p>
</div>
</div>
</div>
</SectionShell>
</template>

View File

@@ -0,0 +1,73 @@
<script setup>
import SectionShell from './SectionShell.vue'
import Card from '@/design-system/components/Card.vue'
</script>
<template>
<SectionShell
eyebrow="Tokens"
title="Typography"
description="Fraunces for display — warm, organic, optical-sized. DM Sans for body and UI — clean and geometric."
>
<section class="grid md:grid-cols-2 gap-6">
<Card tone="paper">
<p class="eyebrow mb-3">Display</p>
<p class="font-display text-5xl font-normal leading-[1.05] mb-3">Fraunces</p>
<p class="text-[14px] text-[var(--color-muted)] leading-relaxed">
Serif with optical sizing. Use for hero, section titles, product names.
</p>
<code class="font-mono text-[11px] text-[var(--color-muted)] block mt-5">var(--font-serif)</code>
</Card>
<Card tone="paper">
<p class="eyebrow mb-3">Body</p>
<p class="font-sans text-5xl font-medium leading-[1.05] mb-3">DM Sans</p>
<p class="text-[14px] text-[var(--color-muted)] leading-relaxed">
Clean geometric sans. Use for body, UI, navigation, labels.
</p>
<code class="font-mono text-[11px] text-[var(--color-muted)] block mt-5">var(--font-sans)</code>
</Card>
</section>
<section>
<h2 class="eyebrow mb-6">Scale</h2>
<div class="divide-y divide-[var(--color-line)] rounded-[var(--radius-md)] border border-[var(--color-line)] bg-[var(--color-paper)] px-6">
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-xs</code>
<span class="text-xs">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-sm</code>
<span class="text-sm">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-base</code>
<span class="text-base">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-lg</code>
<span class="text-lg">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-xl</code>
<span class="text-xl">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-2xl</code>
<span class="text-2xl">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-3xl</code>
<span class="text-3xl">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-4xl</code>
<span class="text-4xl">The quick brown fox</span>
</div>
<div class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-[var(--color-muted)] w-24 shrink-0">text-5xl</code>
<span class="text-5xl">The quick brown fox</span>
</div>
</div>
</section>
</SectionShell>
</template>

29
src/router/index.js Normal file
View File

@@ -0,0 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'home', component: () => import('@/pages/HomePage.vue') },
{
path: '/design',
component: () => import('@/pages/design/DesignLayout.vue'),
meta: { layout: 'none' },
children: [
{ path: '', redirect: '/design/colors' },
{ path: 'colors', name: 'ds-colors', component: () => import('@/pages/design/ColorsSection.vue') },
{ path: 'typography', name: 'ds-typography', component: () => import('@/pages/design/TypographySection.vue') },
{ path: 'radii', name: 'ds-radii', component: () => import('@/pages/design/RadiiSection.vue') },
{ path: 'shadows', name: 'ds-shadows', component: () => import('@/pages/design/ShadowsSection.vue') },
{ path: 'buttons', name: 'ds-buttons', component: () => import('@/pages/design/ButtonsSection.vue') },
{ path: 'badges', name: 'ds-badges', component: () => import('@/pages/design/BadgesSection.vue') },
{ path: 'inputs', name: 'ds-inputs', component: () => import('@/pages/design/InputsSection.vue') },
{ path: 'cards', name: 'ds-cards', component: () => import('@/pages/design/CardsSection.vue') },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior: () => ({ top: 0 }),
})
export default router

16
vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
},
})