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>
7
.env.example
Normal 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
|
||||||
28
README.md
Normal 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
@@ -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
@@ -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
24
package.json
Normal 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
@@ -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 |
BIN
public/products/holste-wasch-soda-500-g-beutel.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/products/kaiser-natron-allzweck-reiniger-750-ml.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/products/kaiser-natron-allzweck-spray-500-ml.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/products/kaiser-natron-bad-500-g (1).webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/products/kaiser-natron-bad-500-g.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/products/kaiser-natron-fussbad-500-g.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/products/kaiser-natron-pulver-250-g-großpackung.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/products/kaiser-natron-pulver-3.490-g-eimer.webp
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
public/products/kaiser-natron-pulver-50-g-beutel.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/products/kaiser-natron-spuelmittel-500-ml.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/products/kaiser-natron-tabletten-100-g-dose.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
21
src/App.vue
Normal 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
@@ -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);
|
||||||
|
}
|
||||||
33
src/design-system/components/Badge.vue
Normal 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>
|
||||||
62
src/design-system/components/Button.vue
Normal 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>
|
||||||
32
src/design-system/components/Card.vue
Normal 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>
|
||||||
55
src/design-system/components/Input.vue
Normal 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>
|
||||||
330
src/design-system/devtools/A11yToolbar.vue
Normal 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>
|
||||||
73
src/design-system/tokens.css
Normal 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);
|
||||||
|
}
|
||||||
87
src/layouts/DefaultLayout.vue
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||||
39
src/pages/design/BadgesSection.vue
Normal 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>
|
||||||
56
src/pages/design/ButtonsSection.vue
Normal 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>
|
||||||
71
src/pages/design/CardsSection.vue
Normal 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>
|
||||||
39
src/pages/design/ColorsSection.vue
Normal 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>
|
||||||
69
src/pages/design/DesignLayout.vue
Normal 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>
|
||||||
58
src/pages/design/InputsSection.vue
Normal 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>
|
||||||
31
src/pages/design/RadiiSection.vue
Normal 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>
|
||||||
24
src/pages/design/SectionShell.vue
Normal 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>
|
||||||
30
src/pages/design/ShadowsSection.vue
Normal 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>
|
||||||
73
src/pages/design/TypographySection.vue
Normal 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
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||