product cards and containerisation

This commit is contained in:
Dorian
2026-04-21 11:27:25 +01:00
parent 20faf91bda
commit 9bc6b842cf
78 changed files with 2585 additions and 245 deletions

35
.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
# Keep the build context small and reproducible.
.git
.github
.gitignore
.DS_Store
# Local development artefacts
node_modules
dist
dist-ssr
.vite
coverage
*.log
# Secrets & env (always excluded; only .env.example is shipped to clients, not the image)
.env
.env.*
!.env.example
# Python scripts/ tooling (rembg etc) — not part of the web image
scripts/.venv
__pycache__
# Editor / IDE
.vscode
.idea
# Claude Code artefacts
.claude
CLAUDE.md
CLAUDE.local.md
.claudeignore
# Docs / meta that don't need to ship in the build context
README.md

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
# Multi-stage build: Node builds the Vite SPA, Nginx serves the static output.
# Pinned tags only — no :latest, no floating minors.
# ── 1. Build ───────────────────────────────────────────────────────────────
FROM node:24.13.0-alpine3.20 AS build
WORKDIR /app
# Copy lockfile first so `npm ci` layer caches when only source changes.
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
# ── 2. Serve ───────────────────────────────────────────────────────────────
FROM nginx:1.27.3-alpine AS serve
# Strip the default site; our config owns /etc/nginx/conf.d/default.conf.
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Drop the built SPA into the document root.
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
# Alpine ships busybox wget — avoids pulling curl just for healthchecks.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O- http://127.0.0.1/health || exit 1

View File

@@ -26,3 +26,14 @@ Browse the full system at `/design` when running `npm run dev`. This is the sing
## 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.
## Deployment (Portainer stack)
Portainer builds the image from the `Dockerfile` at the repo root each time the stack is pulled & redeployed — no registry needed.
1. Portainer → **Stacks****Add stack**
2. Either paste `docker-compose.yml` in the web editor or point Portainer at this repo (build path `/`)
3. Deploy. The site comes up on host port **5555** (internal container port 80).
4. Health: `GET /health` returns `200 ok`.
Pinned images: `node:24.13.0-alpine3.20` (build stage), `nginx:1.27.3-alpine` (serve stage). Bump explicitly when you want to upgrade — no floating tags.

43
docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
# Portainer stack — Kaiser Natron static frontend.
#
# Deploy:
# Portainer → Stacks → Add stack → Repository (point at this repo)
# or Web editor (paste this file).
#
# Portainer will build the image from the Dockerfile at the repo root on first
# deploy and on each "Pull and redeploy".
services:
web:
build:
context: .
dockerfile: Dockerfile
image: kaiser-natron:portainer
container_name: kaiser-natron-web
restart: unless-stopped
ports:
# Host 5555 → container 80. Change the host side if you put a reverse
# proxy in front later; the container always listens on 80 internally.
- "5555:80"
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# Hardening: the Vite output + nginx don't need a writable root. The tmp
# paths nginx uses are carved out as tmpfs so the main FS can be read-only.
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp
security_opt:
- no-new-privileges:true
# Resource ceiling — a static site doesn't need much, and this prevents
# a runaway from starving other stacks on the same host.
deploy:
resources:
limits:
cpus: "0.50"
memory: 128M

66
nginx.conf Normal file
View File

@@ -0,0 +1,66 @@
# Kaiser Natron — SPA static serve config.
# Mounted into /etc/nginx/conf.d/default.conf by the Dockerfile.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ── Compression ───────────────────────────────────────────────────────
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types text/plain
text/css
text/javascript
application/javascript
application/json
image/svg+xml
font/woff2;
# ── Security headers ─────────────────────────────────────────────────
# Mirror the hardening we observed on the live site and tighten where possible.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Keep CSP loose enough for Vue + Tailwind inline styles. Tighten once the
# backend is wired and we know which origins we need to whitelist.
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
# ── Healthcheck ──────────────────────────────────────────────────────
location = /health {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
# ── Hashed build assets — long-cache ─────────────────────────────────
# Vite emits filenames like assets/foo-<hash>.js, safe to cache for a year.
location /assets/ {
access_log off;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# ── Everything else: SPA fallback ────────────────────────────────────
# index.html must never be cached so users pick up new asset hashes on deploy.
location = /index.html {
add_header Cache-Control "no-store, must-revalidate" always;
}
location / {
try_files $uri $uri/ /index.html;
}
# Don't serve dotfiles that somehow end up in the doc root.
location ~ /\. {
deny all;
return 404;
}
}

View File

@@ -4,9 +4,9 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview",
"preview": "vite preview --host",
"audit": "npm audit --omit=dev"
},
"dependencies": {

View File

@@ -1 +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>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1c3a28"/><text x="16" y="22" font-family="'DM Sans', system-ui, sans-serif" font-size="20" font-weight="700" fill="#ffffff" text-anchor="middle">K</text></svg>

Before

Width:  |  Height:  |  Size: 252 B

After

Width:  |  Height:  |  Size: 263 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 121.9 76.9" style="enable-background:new 0 0 121.9 76.9;" xml:space="preserve"> <style type="text/css"> .st0{fill:#FFFFFF;} </style> <g id="Claim"> </g> <g id="Kaiser_Natron"> <g> <g> <g> <polygon class="st0" points="13.8,15.6 6.7,28.7 6.7,17.5 0,19.3 0,46.4 6.8,44.6 6.7,38.4 9.8,33.3 15.3,42.4 22.4,40.4 14.1,25.9 21.3,13.6 "></polygon> <path class="st0" d="M33.1,32c0.6-0.2,1.1-0.4,1.6-0.9l0-7.3c-0.7-0.1-1.3,0-2.1,0.2c-3.1,0.8-3.9,3.2-3.2,5.7 C29.9,31.8,31.2,32.5,33.1,32 M36.3,35.3c-1.1,1.5-2.6,2.6-4.5,3.1c-3.7,1-7.3-0.6-8.8-5.9c-1.7-6.3,1.1-12,7.5-13.7 c2.3-0.6,4.4-0.5,6.1,0.2l1-1.7l3.5-0.9l0,19.1l-3.6,1L36.3,35.3z"></path> <path class="st0" d="M44.9,15.4l6.4-1.7l0,19.1l-6.4,1.7L44.9,15.4z M44.7,5.9l6.6-1.8l0,6.6l-6.7,1.8L44.7,5.9z"></path> <path class="st0" d="M55.6,29.9l-1.3-6.1c2.6,1.1,5.3,1.7,7.2,1.2c0.8-0.2,1.1-0.7,1-1.3c-0.1-0.5-0.6-0.7-2.2-1.1l-0.4-0.1 c-2.9-0.6-4.5-1.8-5.1-4.1c-0.9-3.3,1.1-6.7,5.4-7.8c2.4-0.6,4.9-0.6,6.9,0l0.2,5.6c-1.7-0.7-3.9-0.9-5.1-0.6 c-1,0.3-1.4,0.8-1.2,1.5c0.1,0.4,0.5,0.7,2,1l0.5,0.1c3,0.7,4.3,1.7,4.9,3.8c1,3.6-1.4,6.7-5.6,7.9C60,30.8,57.6,30.8,55.6,29.9 "></path> <path class="st0" d="M76.4,14.7l5.4-1.4c-0.6-1.8-1.7-2.5-3-2.1C77.4,11.5,76.5,12.8,76.4,14.7 M80.5,25.3 c-4.5,1.2-8.6,0.2-10.1-5.4c-1.8-6.5,1.7-12.3,7.2-13.8c4.8-1.3,7.8,1.1,9.1,6.1c0.2,0.7,0.4,1.6,0.4,2.4l-10.4,2.8 c0.8,2.3,2.4,2.5,4.5,1.9c1.8-0.5,3.6-1.5,4.8-2.6l-0.8,5.9C83.9,24,82.4,24.7,80.5,25.3"></path> <path class="st0" d="M96,10l0,10.7l-6.4,1.7l0-19.1l4.7-1.3L95.8,4c1.5-2.5,3.2-3.5,4.9-4l0,7.9C99.1,8.4,97.5,9.1,96,10"></path> <polygon class="st0" points="104.5,6.3 112.6,4.1 112.6,7.1 104.5,9.2 "></polygon> <polygon class="st0" points="15.5,45.6 15.5,59.7 4.6,48.5 0,49.7 0,76.9 6.6,75.1 6.7,60.7 17.6,72.1 22.1,70.9 22.1,43.8 "></polygon> <path class="st0" d="M35.8,61.6c0.6-0.2,1.1-0.4,1.6-0.9l0-7.3c-0.7-0.1-1.3,0-2.1,0.2c-3.1,0.8-3.9,3.2-3.2,5.7 C32.7,61.5,33.9,62.1,35.8,61.6 M39.1,64.9c-1.1,1.5-2.6,2.6-4.5,3.1c-3.7,1-7.3-0.6-8.8-5.9c-1.7-6.3,1.1-12,7.5-13.7 c2.3-0.6,4.4-0.5,6.1,0.2l1-1.7l3.5-0.9l0,19.1l-3.6,1L39.1,64.9z"></path> <path class="st0" d="M54.8,62.5c-3,0.8-5.4,0.3-6.4-3.4C48.2,58,48,56.5,48,54.6l0-4l-1.6,0.4l0-5.7l1.6-0.4l0-5.4l6.4-1.7 l0,5.4l3.1-0.8l0,5.7l-3.1,0.8l0,5.1c0,0.4,0,0.6,0.1,0.9c0.2,0.8,0.8,1,1.8,0.7c0.4-0.1,0.8-0.3,1.3-0.5l-0.5,6.4 C56.4,62,55.6,62.3,54.8,62.5"></path> <path class="st0" d="M67.3,48.1l0,10.7l-6.4,1.7l0-19.1l4.7-1.3l1.5,1.8c1.5-2.5,3.2-3.5,4.9-4l0,7.9 C70.3,46.5,68.8,47.2,67.3,48.1"></path> <path class="st0" d="M86,43.3c-0.5-1.9-1.7-2.7-3.4-2.3c-2.2,0.6-3.2,3-2.4,5.7c0.6,2.1,1.9,2.8,3.4,2.4 C85.8,48.5,86.7,46,86,43.3 M73.9,49.2c-1.6-5.9,1.3-12,8.1-13.9c5.5-1.5,9.1,0.9,10.3,5.4c1.5,5.6-0.9,12-8,13.9 C78.9,56.2,75.2,54.1,73.9,49.2"></path> <path class="st0" d="M101.8,37l0,12.6l-6.4,1.7l0-19.1l3.7-1l2,1.9c1.1-2.3,2.8-3.6,4.6-4.1c2.9-0.8,5.3,0.5,6.3,4.1 c0.3,1.3,0.6,3.3,0.6,5.6l0,7.9l-6.4,1.7l0-8.3c0-1.1,0-2.1-0.2-2.9c-0.3-1.3-1.1-1.5-2.2-1.2C103.2,36.1,102.6,36.4,101.8,37"></path> </g> </g> <path class="st0" d="M117.2,30.5v-2h0.6c0.7,0,1,0.4,1,1c0,0.6-0.4,1-1,1H117.2z M117.2,31.1h0.7c0.1,0,0.2,0,0.3,0l0.7,1.2h0.9 l-1-1.6c0.4-0.3,0.6-0.8,0.6-1.3c0-1-0.7-1.6-1.7-1.6h-1.4v4.5h0.8V31.1z M117.9,33.5c-1.9,0-3.2-1.1-3.2-3.4 c0-2.1,1.4-3.3,3.2-3.3c1.9,0,3.2,1.2,3.2,3.4C121.1,32.3,119.8,33.5,117.9,33.5 M117.8,34.2c2.4,0,4-1.5,4-4.1 c0-2.5-1.6-4.1-4-4.1c-2.3,0-4,1.6-4,4.1C113.8,32.7,115.4,34.2,117.8,34.2"></path> </g> </g> </svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

176
scripts/remove-bg.py Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
Remove white studio backgrounds from product images.
Strategy (hybrid):
1. Run rembg (birefnet-general) to get a coarse foreground mask.
2. Dilate that mask to form a "protected zone" — everything inside
stays opaque, regardless of how white it is. This saves interior
white text / highlights / powder from being eaten by flood-fill.
3. OUTSIDE the protected zone, flood-fill from the image borders
through near-white pixels. This gives precise anti-aliased edges
without touching anything near the product.
4. Fill any orphaned-white holes inside the final foreground mask.
5. Decontaminate RGB at the feather band (alpha bleeding) so the
cutout composites cleanly on any background colour.
Usage:
.venv/bin/python remove-bg.py
.venv/bin/python remove-bg.py --force
.venv/bin/python remove-bg.py --only NAME
.venv/bin/python remove-bg.py --protect 14 --threshold 22 --feather 1.2
Tuning:
--protect Dilation (px) around the ML mask to form the protected
zone. Higher = safer (more interior detail survives),
lower = tighter cutouts. Default 18.
--threshold Max L-inf distance from white to count as hard
background. Only applies OUTSIDE the protected zone.
Default 20.
--soft-band Width (px) of the soft-alpha transition at the product
edge. Default 12.
--feather Gaussian blur radius on the alpha edge, in pixels.
Default 1.0.
--model rembg model for the coarse mask. Default birefnet-general.
"""
import argparse
from pathlib import Path
import numpy as np
from PIL import Image, ImageFilter
from rembg import new_session, remove
from scipy.ndimage import (
binary_dilation,
binary_fill_holes,
distance_transform_edt,
)
ROOT = Path(__file__).resolve().parent.parent
INPUT_DIR = ROOT / "public" / "products"
OUTPUT_DIR = INPUT_DIR / "cutouts"
INPUT_EXTS = (".webp", ".jpg", ".jpeg", ".png")
def coarse_mask(img: Image.Image, session) -> np.ndarray:
cut = remove(img, session=session, only_mask=True, post_process_mask=True)
m = np.array(cut)
if m.ndim == 3:
m = m[:, :, 0]
return m >= 64
def strip_background(
src: Path,
dst: Path,
session,
protect: int,
threshold: int,
soft_band: int,
feather: float,
) -> None:
img = Image.open(src).convert("RGBA")
arr = np.array(img)
rgb = arr[:, :, :3].astype(np.int16)
dist_white = np.max(np.abs(rgb - 255), axis=2)
fg_ml = coarse_mask(img, session)
protected = binary_dilation(fg_ml, iterations=max(protect, 1))
near_white = dist_white <= threshold
bg_seed = near_white & ~protected
border_zone = np.zeros_like(bg_seed)
border_zone[0, :] = border_zone[-1, :] = True
border_zone[:, 0] = border_zone[:, -1] = True
border_near_white = near_white & border_zone
from scipy.ndimage import label
combined = bg_seed | border_near_white
labeled, _ = label(combined)
border_labels = np.unique(
np.concatenate([labeled[0], labeled[-1], labeled[:, 0], labeled[:, -1]])
)
border_labels = border_labels[border_labels != 0]
bg = np.isin(labeled, border_labels) & ~protected
fg = ~bg
fg = binary_fill_holes(fg)
bg = ~fg
alpha = np.where(bg, 0.0, 1.0).astype(np.float32)
soft_edge = binary_dilation(bg, iterations=max(soft_band, 1)) & fg
if soft_band > 0:
soft_alpha = np.clip(
dist_white.astype(np.float32) / max(soft_band, 1), 0.0, 1.0
)
alpha = np.where(soft_edge, soft_alpha, alpha)
alpha = (alpha * 255).astype(np.uint8)
arr[:, :, 3] = alpha
if feather > 0:
a = Image.fromarray(arr[:, :, 3], "L").filter(
ImageFilter.GaussianBlur(radius=feather)
)
arr[:, :, 3] = np.array(a)
opaque = arr[:, :, 3] >= 250
if opaque.any():
_, (iy, ix) = distance_transform_edt(~opaque, return_indices=True)
bleed = arr[:, :, 3] < 250
arr[bleed, 0] = arr[iy[bleed], ix[bleed], 0]
arr[bleed, 1] = arr[iy[bleed], ix[bleed], 1]
arr[bleed, 2] = arr[iy[bleed], ix[bleed], 2]
Image.fromarray(arr, "RGBA").save(dst, format="PNG", optimize=True)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--force", action="store_true")
parser.add_argument("--only", help="stem of a single file to process")
parser.add_argument("--model", default="birefnet-general")
parser.add_argument("--protect", type=int, default=18)
parser.add_argument("--threshold", type=int, default=20)
parser.add_argument("--soft-band", type=int, default=12)
parser.add_argument("--feather", type=float, default=1.0)
args = parser.parse_args()
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
sources = sorted(
p for p in INPUT_DIR.iterdir()
if p.is_file() and p.suffix.lower() in INPUT_EXTS
)
if args.only:
sources = [s for s in sources if s.stem == args.only]
if not sources:
print(f"no match for --only {args.only!r}")
return 1
print(
f"hybrid: model={args.model} protect={args.protect} "
f"threshold={args.threshold} soft-band={args.soft_band} "
f"feather={args.feather}"
)
session = new_session(args.model)
for src in sources:
dst = OUTPUT_DIR / (src.stem + ".png")
if dst.exists() and not args.force:
print(f"skip {src.name}")
continue
print(f"strip {src.name}")
strip_background(
src, dst, session,
protect=args.protect,
threshold=args.threshold,
soft_band=args.soft_band,
feather=args.feather,
)
print(f"done. cutouts at {OUTPUT_DIR.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

32
scripts/requirements.txt Normal file
View File

@@ -0,0 +1,32 @@
attrs==26.1.0
certifi==2026.2.25
charset-normalizer==3.4.7
flatbuffers==25.12.19
idna==3.11
ImageIO==2.37.3
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
lazy-loader==0.5
llvmlite==0.47.0
mpmath==1.3.0
networkx==3.6.1
numba==0.65.0
numpy==2.4.4
onnxruntime==1.24.4
opencv-python-headless==4.13.0.92
packaging==26.1
pillow==12.2.0
platformdirs==4.9.6
pooch==1.9.0
protobuf==7.34.1
PyMatting==1.1.15
referencing==0.37.0
rembg==2.0.69
requests==2.33.1
rpds-py==0.30.0
scikit-image==0.26.0
scipy==1.17.1
sympy==1.14.0
tifffile==2026.4.11
tqdm==4.67.3
urllib3==2.6.3

View File

@@ -1,10 +1,27 @@
<script setup>
import { computed, defineAsyncComponent } from 'vue'
import { computed, ref, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import DefaultLayout from './layouts/DefaultLayout.vue'
import SplashIntro from './components/SplashIntro.vue'
const route = useRoute()
const useDefaultLayout = computed(() => route.meta.layout !== 'none')
const isPreview = computed(() => route.meta.preview === true)
const isDesignRoute = computed(() => route.path.startsWith('/design'))
const inIframe = typeof window !== 'undefined' && window.self !== window.top
// Show the splash once per session, and never inside the DS iframe previews —
// it would replay every time the preview reloads, which is not what we want.
const splashAlreadyShown =
typeof window !== 'undefined' && window.sessionStorage?.getItem('splashed') === '1'
const showSplash = ref(!splashAlreadyShown && !inIframe && !isPreview.value)
function onSplashFinished() {
showSplash.value = false
try {
window.sessionStorage?.setItem('splashed', '1')
} catch {}
}
const isDev = import.meta.env.DEV
const A11yToolbar = isDev
@@ -13,9 +30,10 @@ const A11yToolbar = isDev
</script>
<template>
<SplashIntro v-if="showSplash" @finished="onSplashFinished" />
<DefaultLayout v-if="useDefaultLayout">
<router-view />
</DefaultLayout>
<router-view v-else />
<A11yToolbar v-if="isDev" />
<A11yToolbar v-if="isDev && isDesignRoute && !isPreview && !inIframe" />
</template>

File diff suppressed because one or more lines are too long

View File

@@ -11,18 +11,17 @@ const props = defineProps({
})
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)]',
neutral: 'bg-cream text-muted border border-line',
brand: 'bg-brand text-accent',
accent: 'bg-accent text-accent-ink',
subtle: 'bg-brand-soft-wash text-brand-soft',
success: 'bg-success-wash text-success',
warning: 'bg-warning-wash text-warning',
danger: 'bg-danger-wash text-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)]',
'inline-flex items-center gap-1 px-[11px] py-[5px] rounded-pill text-[11px] font-bold tracking-eyebrow',
props.uppercase ? 'uppercase' : '',
variants[props.variant],
])

View File

@@ -5,7 +5,7 @@ const props = defineProps({
variant: {
type: String,
default: 'primary',
validator: (v) => ['primary', 'secondary', 'ghost', 'danger'].includes(v),
validator: (v) => ['primary', 'accent', 'secondary', 'ghost', 'danger'].includes(v),
},
size: {
type: String,
@@ -22,28 +22,31 @@ 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)]'
'rounded-pill border transition-all duration-base ease-out ' +
'disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-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)]',
'bg-brand text-accent border-brand ' +
'hover:bg-brand-hover hover:-translate-y-0.5 hover:shadow-md',
accent:
'bg-accent text-brand border-accent ' +
'hover:bg-accent-soft hover:-translate-y-0.5 hover:shadow-md',
secondary:
'bg-transparent text-[var(--color-brand)] border-[var(--color-brand)] ' +
'hover:bg-[var(--color-brand)] hover:text-[var(--color-accent)]',
'bg-transparent text-brand border-brand ' +
'hover:bg-brand hover:text-accent',
ghost:
'bg-transparent text-[var(--color-brand)] border-transparent ' +
'hover:bg-[rgba(28,58,40,0.06)]',
'bg-transparent text-brand border-transparent hover:bg-brand-wash',
danger:
'bg-[var(--color-danger)] text-white border-[var(--color-danger)] hover:opacity-90',
'bg-danger text-white border-danger ' +
'hover:opacity-90 hover:-translate-y-0.5 hover:shadow-md',
}
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)]',
sm: 'text-[13px] px-[18px] py-[9px] tracking-label',
md: 'text-[15px] px-[26px] py-[13px] tracking-label',
lg: 'text-[16px] px-[34px] py-[17px] tracking-label',
}
const classes = computed(() => [
@@ -57,6 +60,8 @@ const classes = computed(() => [
<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 name="before" />
<slot />
<slot name="after" />
</button>
</template>

View File

@@ -10,20 +10,20 @@ defineProps({
})
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',
paper: 'bg-paper text-ink border-line',
cream: 'bg-cream text-ink border-line',
brand: 'bg-brand text-accent border-transparent',
}
</script>
<template>
<div
class="rounded-[var(--radius-md)] border"
class="rounded-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'
? 'transition-all duration-base ease-out hover:-translate-y-1 hover:shadow-md hover:border-brand-soft cursor-pointer'
: '',
]"
>

View File

@@ -0,0 +1,70 @@
<script setup>
import { ref, computed } from 'vue'
import Icon from './Icon.vue'
const props = defineProps({
src: { type: String, required: true },
initial: {
type: String,
default: 'desktop',
validator: (v) => ['mobile', 'tablet', 'desktop'].includes(v),
},
height: { type: Number, default: 560 },
})
const devices = [
{ id: 'mobile', label: 'Mobile', width: 390 },
{ id: 'tablet', label: 'Tablet', width: 820 },
{ id: 'desktop', label: 'Desktop', width: 1280 },
]
const current = ref(props.initial)
const device = computed(() => devices.find((d) => d.id === current.value))
</script>
<template>
<div>
<div class="flex items-center gap-3 mb-4 flex-wrap">
<slot name="controls" />
<div
role="tablist"
aria-label="Preview viewport"
class="inline-flex items-center p-1 gap-0.5 rounded-pill border border-line bg-paper ml-auto"
>
<button
v-for="d in devices"
:key="d.id"
type="button"
role="tab"
:aria-selected="current === d.id"
:class="[
'inline-flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold tracking-label rounded-pill transition-colors duration-base',
current === d.id ? 'bg-brand text-accent' : 'text-muted hover:text-brand',
]"
@click="current = d.id"
>
<Icon :name="d.id" :size="14" />
{{ d.label }}
</button>
</div>
</div>
<div class="rounded-md border border-line bg-surface p-6 overflow-x-auto">
<div
class="mx-auto transition-[width] duration-base ease-out"
:style="{ width: device.width + 'px' }"
>
<iframe
:src="src"
:title="`${device.label} preview`"
:style="{ height: height + 'px' }"
class="w-full block rounded-sm border border-line bg-paper"
loading="lazy"
/>
<p class="mt-2 text-center font-mono text-[11px] text-muted">
{{ device.width }}px
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { computed } from 'vue'
import { icons } from './icons.js'
const props = defineProps({
name: { type: String, required: true },
size: { type: [String, Number], default: 20 },
strokeWidth: { type: [String, Number], default: 1.8 },
label: { type: String, default: null },
})
const icon = computed(() => icons[props.name])
const a11yLabel = computed(() => props.label ?? null)
</script>
<template>
<svg
v-if="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
:width="size"
:height="size"
fill="none"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
:role="a11yLabel ? 'img' : 'presentation'"
:aria-label="a11yLabel"
:aria-hidden="a11yLabel ? null : 'true'"
v-html="icon.path"
/>
</template>

View File

@@ -28,9 +28,9 @@ const describedBy = computed(
<label
v-if="label"
:for="inputId"
class="text-[11px] font-bold uppercase tracking-[var(--tracking-eyebrow)] text-[var(--color-muted)]"
class="text-[11px] font-bold uppercase tracking-eyebrow text-muted"
>
{{ label }}<span v-if="required" class="text-[var(--color-danger)]"> *</span>
{{ label }}<span v-if="required" class="text-danger"> *</span>
</label>
<input
:id="inputId"
@@ -41,15 +41,15 @@ const describedBy = computed(
: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)]
class="w-full rounded-sm border bg-paper px-4 py-3 text-[15px] text-ink
placeholder:text-ink-placeholder
transition-colors duration-base
focus:outline-none focus:border-brand
disabled:opacity-50 disabled:cursor-not-allowed"
:class="error ? 'border-[var(--color-danger)]' : 'border-[var(--color-line)]'"
:class="error ? 'border-danger' : 'border-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>
<p v-if="hint && !error" :id="hintId" class="text-[13px] text-muted">{{ hint }}</p>
<p v-if="error" :id="errorId" class="text-[13px] text-danger">{{ error }}</p>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { computed } from 'vue'
import { useI18n } from '@/i18n/index.js'
const props = defineProps({
floating: { type: Boolean, default: false },
/**
* Tone — matches the three surface tones used elsewhere in the system:
* - 'paper': dark ink on a paper container (use on light/white surfaces)
* - 'cream': paper container with a stronger border (use on cream surfaces)
* - 'brand': cream ink on a translucent cream container (use on brand-green surfaces)
*/
tone: {
type: String,
default: 'paper',
validator: (t) => ['paper', 'cream', 'brand'].includes(t),
},
})
const { locale, setLocale, availableLocales } = useI18n()
const tones = {
paper: {
container: 'border border-line bg-paper',
active: 'bg-brand text-accent',
inactive: 'text-muted hover:text-brand',
},
cream: {
container: 'border border-line-strong bg-paper',
active: 'bg-brand text-accent',
inactive: 'text-muted hover:text-brand',
},
brand: {
container: 'border border-cream-line bg-cream-wash',
active: 'bg-accent text-brand',
inactive: 'text-cream hover:text-accent',
},
}
const t = computed(() => tones[props.tone])
</script>
<template>
<div
role="group"
aria-label="Language"
:class="[
'inline-flex items-center p-1 gap-0.5 rounded-pill font-sans',
t.container,
floating ? 'fixed top-6 right-6 z-[60] shadow-sm' : '',
]"
>
<button
v-for="l in availableLocales"
:key="l.code"
type="button"
:aria-label="l.name"
:aria-pressed="locale === l.code"
:class="[
'px-2.5 py-1 text-[11px] font-bold tracking-eyebrow rounded-pill transition-colors duration-base',
locale === l.code ? t.active : t.inactive,
]"
@click="setLocale(l.code)"
>{{ l.label }}</button>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
defineProps({
title: { type: String, default: 'Kaiser Natron' },
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 121.9 76.9"
role="img"
:aria-label="title"
fill="currentColor"
>
<title>{{ title }}</title>
<g>
<polygon points="13.8,15.6 6.7,28.7 6.7,17.5 0,19.3 0,46.4 6.8,44.6 6.7,38.4 9.8,33.3 15.3,42.4 22.4,40.4 14.1,25.9 21.3,13.6" />
<path d="M33.1,32c0.6-0.2,1.1-0.4,1.6-0.9l0-7.3c-0.7-0.1-1.3,0-2.1,0.2c-3.1,0.8-3.9,3.2-3.2,5.7 C29.9,31.8,31.2,32.5,33.1,32 M36.3,35.3c-1.1,1.5-2.6,2.6-4.5,3.1c-3.7,1-7.3-0.6-8.8-5.9c-1.7-6.3,1.1-12,7.5-13.7 c2.3-0.6,4.4-0.5,6.1,0.2l1-1.7l3.5-0.9l0,19.1l-3.6,1L36.3,35.3z" />
<path d="M44.9,15.4l6.4-1.7l0,19.1l-6.4,1.7L44.9,15.4z M44.7,5.9l6.6-1.8l0,6.6l-6.7,1.8L44.7,5.9z" />
<path d="M55.6,29.9l-1.3-6.1c2.6,1.1,5.3,1.7,7.2,1.2c0.8-0.2,1.1-0.7,1-1.3c-0.1-0.5-0.6-0.7-2.2-1.1l-0.4-0.1 c-2.9-0.6-4.5-1.8-5.1-4.1c-0.9-3.3,1.1-6.7,5.4-7.8c2.4-0.6,4.9-0.6,6.9,0l0.2,5.6c-1.7-0.7-3.9-0.9-5.1-0.6 c-1,0.3-1.4,0.8-1.2,1.5c0.1,0.4,0.5,0.7,2,1l0.5,0.1c3,0.7,4.3,1.7,4.9,3.8c1,3.6-1.4,6.7-5.6,7.9C60,30.8,57.6,30.8,55.6,29.9" />
<path d="M76.4,14.7l5.4-1.4c-0.6-1.8-1.7-2.5-3-2.1C77.4,11.5,76.5,12.8,76.4,14.7 M80.5,25.3 c-4.5,1.2-8.6,0.2-10.1-5.4c-1.8-6.5,1.7-12.3,7.2-13.8c4.8-1.3,7.8,1.1,9.1,6.1c0.2,0.7,0.4,1.6,0.4,2.4l-10.4,2.8 c0.8,2.3,2.4,2.5,4.5,1.9c1.8-0.5,3.6-1.5,4.8-2.6l-0.8,5.9C83.9,24,82.4,24.7,80.5,25.3" />
<path d="M96,10l0,10.7l-6.4,1.7l0-19.1l4.7-1.3L95.8,4c1.5-2.5,3.2-3.5,4.9-4l0,7.9C99.1,8.4,97.5,9.1,96,10" />
<polygon points="104.5,6.3 112.6,4.1 112.6,7.1 104.5,9.2" />
<polygon points="15.5,45.6 15.5,59.7 4.6,48.5 0,49.7 0,76.9 6.6,75.1 6.7,60.7 17.6,72.1 22.1,70.9 22.1,43.8" />
<path d="M35.8,61.6c0.6-0.2,1.1-0.4,1.6-0.9l0-7.3c-0.7-0.1-1.3,0-2.1,0.2c-3.1,0.8-3.9,3.2-3.2,5.7 C32.7,61.5,33.9,62.1,35.8,61.6 M39.1,64.9c-1.1,1.5-2.6,2.6-4.5,3.1c-3.7,1-7.3-0.6-8.8-5.9c-1.7-6.3,1.1-12,7.5-13.7 c2.3-0.6,4.4-0.5,6.1,0.2l1-1.7l3.5-0.9l0,19.1l-3.6,1L39.1,64.9z" />
<path d="M54.8,62.5c-3,0.8-5.4,0.3-6.4-3.4C48.2,58,48,56.5,48,54.6l0-4l-1.6,0.4l0-5.7l1.6-0.4l0-5.4l6.4-1.7 l0,5.4l3.1-0.8l0,5.7l-3.1,0.8l0,5.1c0,0.4,0,0.6,0.1,0.9c0.2,0.8,0.8,1,1.8,0.7c0.4-0.1,0.8-0.3,1.3-0.5l-0.5,6.4 C56.4,62,55.6,62.3,54.8,62.5" />
<path d="M67.3,48.1l0,10.7l-6.4,1.7l0-19.1l4.7-1.3l1.5,1.8c1.5-2.5,3.2-3.5,4.9-4l0,7.9 C70.3,46.5,68.8,47.2,67.3,48.1" />
<path d="M86,43.3c-0.5-1.9-1.7-2.7-3.4-2.3c-2.2,0.6-3.2,3-2.4,5.7c0.6,2.1,1.9,2.8,3.4,2.4 C85.8,48.5,86.7,46,86,43.3 M73.9,49.2c-1.6-5.9,1.3-12,8.1-13.9c5.5-1.5,9.1,0.9,10.3,5.4c1.5,5.6-0.9,12-8,13.9 C78.9,56.2,75.2,54.1,73.9,49.2" />
<path d="M101.8,37l0,12.6l-6.4,1.7l0-19.1l3.7-1l2,1.9c1.1-2.3,2.8-3.6,4.6-4.1c2.9-0.8,5.3,0.5,6.3,4.1 c0.3,1.3,0.6,3.3,0.6,5.6l0,7.9l-6.4,1.7l0-8.3c0-1.1,0-2.1-0.2-2.9c-0.3-1.3-1.1-1.5-2.2-1.2C103.2,36.1,102.6,36.4,101.8,37" />
<path d="M117.2,30.5v-2h0.6c0.7,0,1,0.4,1,1c0,0.6-0.4,1-1,1H117.2z M117.2,31.1h0.7c0.1,0,0.2,0,0.3,0l0.7,1.2h0.9 l-1-1.6c0.4-0.3,0.6-0.8,0.6-1.3c0-1-0.7-1.6-1.7-1.6h-1.4v4.5h0.8V31.1z M117.9,33.5c-1.9,0-3.2-1.1-3.2-3.4 c0-2.1,1.4-3.3,3.2-3.3c1.9,0,3.2,1.2,3.2,3.4C121.1,32.3,119.8,33.5,117.9,33.5 M117.8,34.2c2.4,0,4-1.5,4-4.1 c0-2.5-1.6-4.1-4-4.1c-2.3,0-4,1.6-4,4.1C113.8,32.7,115.4,34.2,117.8,34.2" />
</g>
</svg>
</template>

View File

@@ -0,0 +1,240 @@
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import Logo from './Logo.vue'
import Icon from './Icon.vue'
import LanguageSwitcher from './LanguageSwitcher.vue'
import { useI18n } from '@/i18n/index.js'
const props = defineProps({
variant: {
type: String,
default: 'paper',
validator: (v) => ['brand', 'cream', 'paper'].includes(v),
},
/**
* Layout:
* - 'standard': edge-to-edge bar with bottom border (static at top of page)
* - 'floating': sticky rounded card that detaches from page edges with a shadow
*/
layout: {
type: String,
default: 'standard',
validator: (v) => ['standard', 'floating'].includes(v),
},
items: {
type: Array,
default: () => [
{ key: 'nav.shop', href: '#' },
{ key: 'nav.applications', href: '#' },
{ key: 'nav.about', href: '#' },
{ key: 'nav.contact', href: '#' },
],
},
cartCount: { type: Number, default: 0 },
floatOnMobile: { type: Boolean, default: true },
})
defineEmits(['cart', 'nav'])
const { t } = useI18n()
const menuOpen = ref(false)
// Cart is always warm yellow — one consistent affordance on mobile and desktop,
// regardless of bar tone.
const CART_CLASS = 'bg-accent text-brand hover:bg-accent-soft'
const tones = {
brand: {
bar: 'bg-brand text-cream border-cream-line',
link: 'text-cream hover:text-accent',
logo: 'text-cream',
},
cream: {
bar: 'bg-cream text-brand border-line',
link: 'text-brand hover:text-brand-hover',
logo: 'text-brand',
},
paper: {
bar: 'bg-paper text-ink border-line',
link: 'text-ink hover:text-brand',
logo: 'text-brand',
},
}
const tone = computed(() => tones[props.variant])
// Both layouts stay pinned at the top of the scroll container while the user
// scrolls. `sticky` keeps it in normal document flow until it hits the top edge,
// which avoids the layout-shift that `position: fixed` would introduce.
const layoutClasses = computed(() =>
props.layout === 'floating'
? 'sticky top-4 md:top-6 z-30 mx-4 md:mx-6 rounded-lg border shadow-md'
: 'sticky top-0 z-30 border-b',
)
// Edge-to-edge: the bar's contents span the full bar width. Logo sits at the
// far left, language + cart cluster at the far right. No centered max-width
// container — matches the live kaiser-natron.at layout.
const innerPadding = computed(() =>
props.layout === 'floating'
? 'px-6 md:pl-4 md:pr-8 py-3 md:py-3.5'
: 'px-6 md:px-8 lg:px-10 py-5',
)
const logoClasses = computed(() =>
props.layout === 'floating'
? 'w-9 md:w-16 h-auto'
: 'w-12 md:w-24 h-auto',
)
function itemLabel(item) {
return item.key ? t(item.key) : item.label
}
watch(menuOpen, (open) => {
if (typeof document === 'undefined') return
document.documentElement.style.overflow = open ? 'hidden' : ''
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') document.documentElement.style.overflow = ''
})
</script>
<template>
<header :class="['font-sans', layoutClasses, tone.bar]">
<div :class="[innerPadding, 'flex items-center justify-between gap-6']">
<!-- Left: logo + desktop nav -->
<div class="flex items-center gap-10 min-w-0">
<a
href="/"
:class="['block shrink-0 py-1', tone.logo]"
aria-label="Kaiser Natron home"
>
<Logo :class="logoClasses" />
</a>
<nav class="hidden md:flex items-center gap-7">
<a
v-for="item in items"
:key="item.key || item.label"
:href="item.href || '#'"
:class="[tone.link, 'text-[14px] font-medium tracking-label transition-colors duration-base']"
@click="$emit('nav', item)"
>{{ itemLabel(item) }}</a>
</nav>
</div>
<!-- Right: language + cart (desktop only) -->
<div class="hidden md:flex items-center gap-4">
<LanguageSwitcher :tone="variant" />
<button
type="button"
:class="[CART_CLASS, 'relative inline-flex items-center justify-center w-11 h-11 rounded-full transition-all duration-base ease-out hover:-translate-y-0.5']"
:aria-label="t('cart.open')"
@click="$emit('cart')"
>
<Icon name="cart" :size="20" />
<span
v-if="cartCount > 0"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-danger text-white text-[10px] font-bold flex items-center justify-center"
>{{ cartCount }}</span>
</button>
</div>
</div>
<!-- Mobile floating cluster (bottom-right) -->
<div
v-if="floatOnMobile"
class="md:hidden fixed bottom-5 right-5 z-40 flex items-center gap-3"
style="padding-bottom: env(safe-area-inset-bottom);"
>
<button
type="button"
:class="[CART_CLASS, 'relative w-14 h-14 rounded-full shadow-md flex items-center justify-center transition-transform duration-base ease-out hover:-translate-y-0.5 active:translate-y-0']"
:aria-label="t('cart.open')"
@click="$emit('cart')"
>
<Icon name="cart" :size="22" />
<span
v-if="cartCount > 0"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-danger text-white text-[10px] font-bold flex items-center justify-center"
>{{ cartCount }}</span>
</button>
<button
type="button"
class="w-14 h-14 rounded-full bg-brand text-accent shadow-lg flex items-center justify-center transition-transform duration-base ease-out hover:-translate-y-0.5 active:translate-y-0"
:aria-label="t('menu.open')"
@click="menuOpen = true"
>
<Icon name="menu" :size="24" :stroke-width="2" />
</button>
</div>
<!-- Mobile overlay menu -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-slow ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-base ease-out"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div
v-if="menuOpen"
class="fixed inset-0 z-50 bg-brand text-cream flex flex-col font-sans"
>
<div
class="flex items-center px-6 pt-6 pb-4"
style="padding-top: calc(env(safe-area-inset-top) + 1.5rem);"
>
<Logo class="w-12 h-auto text-cream" />
</div>
<nav class="flex-1 flex flex-col justify-center px-8 gap-3 overflow-y-auto">
<a
v-for="item in items"
:key="item.key || item.label"
:href="item.href || '#'"
class="font-serif font-normal text-[clamp(2.25rem,9vw,3.5rem)] tracking-tight leading-[1.05] text-cream hover:text-accent transition-colors duration-base"
@click="menuOpen = false; $emit('nav', item)"
>{{ itemLabel(item) }}</a>
</nav>
<!-- Language selector above the cart/close row, styled for brand green -->
<div class="px-6 pb-6 flex justify-center">
<LanguageSwitcher tone="brand" />
</div>
<div
class="px-6 py-6 border-t border-cream-line flex items-center gap-3"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem);"
>
<button
type="button"
class="flex-1 inline-flex items-center justify-between px-6 py-4 rounded-pill bg-accent text-brand font-semibold tracking-label hover:bg-accent-soft transition-colors"
@click="menuOpen = false; $emit('cart')"
>
<span class="inline-flex items-center gap-3">
<Icon name="cart" :size="20" />
{{ t('cart.label') }}
</span>
<span
v-if="cartCount > 0"
class="min-w-[22px] h-[22px] px-2 rounded-full bg-brand text-accent text-[12px] font-bold flex items-center justify-center"
>{{ cartCount }}</span>
</button>
<button
type="button"
class="shrink-0 w-14 h-14 rounded-full bg-cream-wash text-cream hover:bg-cream-wash-strong transition-colors flex items-center justify-center"
:aria-label="t('menu.close')"
@click="menuOpen = false"
>
<Icon name="close" :size="20" />
</button>
</div>
</div>
</Transition>
</Teleport>
</header>
</template>

View File

@@ -0,0 +1,123 @@
<script setup>
import { computed } from 'vue'
import Button from './Button.vue'
import Badge from './Badge.vue'
import Icon from './Icon.vue'
import { useI18n } from '@/i18n/index.js'
const props = defineProps({
title: { type: String, required: true },
size: { type: String, default: '' },
price: { type: [String, Number], required: true },
currency: { type: String, default: '€' },
image: { type: String, required: true },
imageAlt: { type: String, default: '' },
badge: { type: String, default: '' },
badgeVariant: {
type: String,
default: 'accent',
validator: (v) => ['neutral', 'brand', 'accent', 'subtle', 'success', 'warning', 'danger'].includes(v),
},
tone: {
type: String,
default: 'paper',
validator: (t) => ['paper', 'cream'].includes(t),
},
inStock: { type: Boolean, default: true },
href: { type: String, default: '' },
})
defineEmits(['add'])
const { t } = useI18n()
const tones = {
paper: {
surface: 'bg-paper',
media: 'bg-cream',
border: 'border-line',
},
cream: {
surface: 'bg-cream',
media: 'bg-paper',
border: 'border-line',
},
}
const tone = computed(() => tones[props.tone])
const priceLabel = computed(() => {
if (typeof props.price === 'number') {
return `${props.currency} ${props.price.toFixed(2).replace('.', ',')}`
}
return `${props.currency} ${props.price}`
})
</script>
<template>
<article
:class="[
'group flex flex-col overflow-hidden rounded-md border transition-all duration-base ease-out',
tone.surface,
tone.border,
'hover:-translate-y-1 hover:shadow-md hover:border-brand-soft',
]"
>
<!-- Media -->
<component
:is="href ? 'a' : 'div'"
:href="href || null"
:class="[
'relative block aspect-[4/5] overflow-hidden',
tone.media,
]"
>
<Badge
v-if="badge"
:variant="badgeVariant"
class="absolute top-4 left-4 z-[1]"
>{{ badge }}</Badge>
<img
:src="image"
:alt="imageAlt || title"
loading="lazy"
decoding="async"
class="absolute inset-0 w-full h-full object-contain p-8 transition-transform duration-slow ease-out group-hover:scale-105"
/>
</component>
<!-- Body -->
<div class="flex flex-col gap-3 p-6">
<div class="flex flex-col gap-1">
<component
:is="href ? 'a' : 'h3'"
:href="href || null"
:class="[
'font-display text-xl font-normal leading-tight text-ink',
href ? 'hover:text-brand transition-colors duration-base' : '',
]"
>{{ title }}</component>
<p v-if="size" class="text-sm text-muted tracking-label">{{ size }}</p>
</div>
<div class="mt-auto flex items-center justify-between gap-3 pt-2">
<span class="font-display text-2xl font-normal text-brand">{{ priceLabel }}</span>
<span
v-if="!inStock"
class="text-xs font-semibold tracking-label uppercase text-danger"
>{{ t('ds.product.outOfStock') }}</span>
</div>
<Button
variant="primary"
size="md"
block
:disabled="!inStock"
@click="$emit('add')"
>
<template #before><Icon name="plus" :size="16" /></template>
{{ t('ds.buttons.addToCart') }}
</Button>
</div>
</article>
</template>

View File

@@ -0,0 +1,85 @@
// Icon registry — 24x24 viewBox, stroke-based, rendered by Icon.vue via currentColor.
// Add entries here to expose them as <Icon name="..." />.
export const icons = {
// Commerce
cart: {
path: '<path d="M6 7h12l-1.2 10.3a2 2 0 0 1-2 1.7H9.2a2 2 0 0 1-2-1.7L6 7Z" /><path d="M9 10V6a3 3 0 1 1 6 0v4" />',
label: 'Warenkorb',
},
bag: {
path: '<path d="M5 7h14v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V7Z" /><path d="M9 7V5a3 3 0 1 1 6 0v2" />',
label: 'Tasche',
},
heart: {
path: '<path d="M12 20s-7-4.35-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.65-7 10-7 10Z" />',
label: 'Favorit',
},
user: {
path: '<circle cx="12" cy="8" r="3.5" /><path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6" />',
label: 'Konto',
},
search: {
path: '<circle cx="11" cy="11" r="6.5" /><path d="m20 20-3.5-3.5" />',
label: 'Suche',
},
// Navigation
menu: { path: '<path d="M5 7h14M5 12h14M5 17h14" />', label: 'Menü' },
close: { path: '<path d="M6 6l12 12M18 6L6 18" />', label: 'Schließen' },
'chevron-left': { path: '<path d="M14 6l-6 6 6 6" />', label: 'Zurück' },
'chevron-right': { path: '<path d="M10 6l6 6-6 6" />', label: 'Weiter' },
'chevron-down': { path: '<path d="M6 10l6 6 6-6" />', label: 'Öffnen' },
'chevron-up': { path: '<path d="M6 14l6-6 6 6" />', label: 'Schließen' },
'arrow-right': { path: '<path d="M5 12h14M13 6l6 6-6 6" />', label: 'Pfeil rechts' },
'arrow-left': { path: '<path d="M19 12H5M11 6l-6 6 6 6" />', label: 'Pfeil links' },
// Actions
plus: { path: '<path d="M12 5v14M5 12h14" />', label: 'Hinzufügen' },
minus: { path: '<path d="M5 12h14" />', label: 'Entfernen' },
check: { path: '<path d="m5 12 5 5L20 7" />', label: 'Bestätigt' },
// Contact
mail: {
path: '<rect x="3" y="5" width="18" height="14" rx="2" /><path d="m3 7 9 6 9-6" />',
label: 'E-Mail',
},
phone: {
path: '<path d="M21 16.5v3a2 2 0 0 1-2.2 2 19 19 0 0 1-8.3-3 19 19 0 0 1-6-6A19 19 0 0 1 1.5 4.2 2 2 0 0 1 3.5 2h3a2 2 0 0 1 2 1.7c.1.9.3 1.8.6 2.6a2 2 0 0 1-.5 2L7.3 9.6a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2-.5c.8.3 1.7.5 2.6.6a2 2 0 0 1 1.7 2Z" />',
label: 'Telefon',
},
'map-pin': {
path: '<path d="M12 21s7-6 7-11a7 7 0 1 0-14 0c0 5 7 11 7 11Z" /><circle cx="12" cy="10" r="2.5" />',
label: 'Standort',
},
'external-link': {
path: '<path d="M14 5h5v5" /><path d="M19 5l-9 9" /><path d="M19 14v5H5V5h5" />',
label: 'Externer Link',
},
// Feedback
info: {
path: '<circle cx="12" cy="12" r="9" /><path d="M12 11v5" /><circle cx="12" cy="8" r="0.6" fill="currentColor" stroke="none" />',
label: 'Info',
},
star: {
path: '<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.2L12 17.3 6.5 20.2l1.1-6.2L3 9.6l6.3-.9Z" />',
label: 'Stern',
},
// Devices
mobile: {
path: '<rect x="7" y="3" width="10" height="18" rx="2" /><path d="M11 18h2" />',
label: 'Mobile',
},
tablet: {
path: '<rect x="4" y="3" width="16" height="18" rx="2" /><path d="M11 18h2" />',
label: 'Tablet',
},
desktop: {
path: '<rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8M12 16v4" />',
label: 'Desktop',
},
}
export const iconNames = Object.keys(icons)

View File

@@ -19,6 +19,7 @@
/* Neutrals — warm, green-tinted */
--color-ink: #0d1f13;
--color-ink-placeholder: rgba(13, 31, 19, 0.35);
--color-muted: #5a7866;
--color-line: rgba(28, 58, 40, 0.11);
--color-line-strong: rgba(28, 58, 40, 0.22);
@@ -26,10 +27,20 @@
--color-surface: #faf7f1;
--color-paper: #ffffff;
/* Washes — tinted fills for hover / subtle backgrounds / overlay chrome */
--color-brand-wash: rgba(28, 58, 40, 0.06);
--color-brand-soft-wash: rgba(61, 122, 85, 0.08);
--color-cream-wash: rgba(244, 239, 228, 0.08);
--color-cream-wash-strong: rgba(244, 239, 228, 0.16);
--color-cream-line: rgba(244, 239, 228, 0.15);
/* Semantic */
--color-success: #3d7a55;
--color-success-wash: rgba(61, 122, 85, 0.12);
--color-warning: #c6900f;
--color-warning-wash: rgba(198, 144, 15, 0.15);
--color-danger: #b23a2a;
--color-danger-wash: rgba(178, 58, 42, 0.12);
/* ——— Typography ———————————————————————————————————————————— */
--font-serif: 'Fraunces', ui-serif, Georgia, 'Times New Roman', serif;

50
src/i18n/index.js Normal file
View File

@@ -0,0 +1,50 @@
// Lightweight, reactive i18n with localStorage persistence.
// Also syncs across same-origin windows (parent ↔ iframes) via the 'storage' event,
// so the design-system preview iframe updates when you switch language in the host.
import { reactive, computed } from 'vue'
import messages from './messages.js'
const STORAGE_KEY = 'kn-locale'
const DEFAULT_LOCALE = 'de'
export const availableLocales = [
{ code: 'de', label: 'DE', name: 'Deutsch' },
{ code: 'at', label: 'AT', name: 'Österreich' },
{ code: 'en', label: 'EN', name: 'English' },
]
const codes = availableLocales.map((l) => l.code)
const fromStorage = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const initial = codes.includes(fromStorage) ? fromStorage : DEFAULT_LOCALE
const state = reactive({ locale: initial })
if (typeof document !== 'undefined') document.documentElement.lang = state.locale
export function setLocale(code) {
if (!codes.includes(code) || state.locale === code) return
state.locale = code
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, code)
if (typeof document !== 'undefined') document.documentElement.lang = code
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key !== STORAGE_KEY || !codes.includes(event.newValue)) return
state.locale = event.newValue
if (typeof document !== 'undefined') document.documentElement.lang = event.newValue
})
}
export function useI18n() {
const locale = computed({
get: () => state.locale,
set: setLocale,
})
const t = (key) => {
const bundle = messages[state.locale] || messages[DEFAULT_LOCALE]
return bundle[key] ?? messages[DEFAULT_LOCALE][key] ?? key
}
return { locale, setLocale, t, availableLocales }
}

403
src/i18n/messages.js Normal file
View File

@@ -0,0 +1,403 @@
// i18n message catalog. Keyed strings, lookup order: current locale → default → key itself.
// DE is the base. AT inherits DE and overrides only Austrian-specific terms.
// EN is a separate catalog.
const de = {
// Navbar (also used by the Navbar preview)
'nav.shop': 'Shop',
'nav.applications': 'Anwendungen',
'nav.about': 'Über uns',
'nav.contact': 'Kontakt',
'cart.open': 'Warenkorb öffnen',
'cart.label': 'Warenkorb',
'menu.open': 'Menü öffnen',
'menu.close': 'Menü schließen',
'language.label': 'Sprache',
// Design-system sidebar
'ds.sidebar.brand': 'Marke',
'ds.sidebar.tokens': 'Tokens',
'ds.sidebar.components': 'Komponenten',
'ds.sidebar.back': '← Zurück zur Website',
'ds.nav.logo': 'Logo',
'ds.nav.colors': 'Farben',
'ds.nav.typography': 'Typografie',
'ds.nav.radii': 'Radien',
'ds.nav.shadows': 'Schatten',
'ds.nav.icons': 'Icons',
'ds.nav.buttons': 'Buttons',
'ds.nav.badges': 'Badges',
'ds.nav.inputs': 'Eingabefelder',
'ds.nav.cards': 'Karten',
'ds.nav.products': 'Produktkarten',
'ds.nav.navbar': 'Navigation',
'ds.nav.language': 'Sprachwahl',
// Common section eyebrows
'ds.eyebrow.brand': 'Marke',
'ds.eyebrow.tokens': 'Tokens',
'ds.eyebrow.components': 'Komponenten',
'ds.eyebrow.designSystem': 'Design System',
// Common section headings
'ds.heading.variants': 'Varianten',
'ds.heading.sizes': 'Größen',
'ds.heading.states': 'Zustände',
'ds.heading.withIcons': 'Mit Icons',
'ds.heading.onBrandSurface': 'Auf Markenfläche',
'ds.heading.block': 'Block',
'ds.heading.usage': 'Verwendung',
'ds.heading.default': 'Standard',
'ds.heading.tones': 'Töne',
'ds.heading.interactive': 'Interaktiv',
'ds.heading.withoutPadding': 'Ohne Innenabstand',
'ds.heading.scale': 'Skala',
'ds.heading.display': 'Display',
'ds.heading.body': 'Fließtext',
'ds.heading.nonUppercase': 'Ohne Großbuchstaben',
'ds.heading.onDifferentSurfaces': 'Auf verschiedenen Flächen',
'ds.heading.greenOnPaper': 'Grün auf Papier',
'ds.heading.creamOnBrand': 'Creme auf Markengrün',
// Logo
'ds.logo.title': 'Logo',
'ds.logo.description': 'Ein einzelnes SVG, das die Farbe über CSS `color` erbt. Verwende das grüne Wortbild auf hellen Flächen und das cremefarbene auf dem Markengrün.',
'ds.logo.usageIntro': 'Farbe über Utility-Klassen setzen:',
// Colors
'ds.colors.title': 'Farben',
'ds.colors.description': 'Pinie-Grün auf Creme mit warmem Gelb als Akzent. Alle UI-Farben fließen aus diesen Tokens — niemals Werte in Komponenten hartkodieren.',
'ds.colors.group.brand': 'Marke',
'ds.colors.group.accent': 'Akzent',
'ds.colors.group.surface': 'Fläche',
'ds.colors.group.ink': 'Schrift',
'ds.colors.group.line': 'Linie',
'ds.colors.group.semantic': 'Semantisch',
// Typography
'ds.typography.title': 'Typografie',
'ds.typography.description': 'Fraunces für Display — warm, organisch, mit optischen Größen. DM Sans für Fließtext und UI — klar und geometrisch.',
'ds.typography.serifDesc': 'Serife mit optischer Skalierung. Für Hero-Bereiche, Section-Titel und Produktnamen.',
'ds.typography.sansDesc': 'Klare geometrische Sans-Serif. Für Fließtext, UI, Navigation und Labels.',
'ds.typography.sample': 'Der schnelle braune Fuchs',
// Radii
'ds.radii.title': 'Radien',
'ds.radii.description': 'Von dezenten 6px an kleinen Elementen bis hin zu vollen Pillen auf Buttons. Passend zum weichen, organischen Gefühl der Referenzseite.',
// Shadows
'ds.shadows.title': 'Schatten',
'ds.shadows.description': 'Alle Schatten sind grünlich statt neutral schwarz getönt — sie wirken warm und sind Teil der Palette.',
'ds.shadows.sm.note': 'Dezent — Nav beim Scrollen, ruhende Karten',
'ds.shadows.md.note': 'Mittel — Hover des primären Buttons',
'ds.shadows.lg.note': 'Groß — schwebende Karten, Overlays',
// Icons
'ds.icons.title': 'Icons',
'ds.icons.description': 'Ein kuratiertes strich-basiertes Icon-Set im 24x24-Raster. Wird über die Icon-Komponente gerendert — erbt die Farbe von currentColor.',
'ds.icons.search': 'Icons suchen…',
'ds.icons.copyHint': 'Klick auf eine Kachel kopiert das Snippet.',
'ds.icons.copied': 'Kopiert!',
'ds.icons.noMatch': 'Keine Icons stimmen mit',
'ds.icons.sizeDefault': '20 (Standard)',
'ds.icons.addHint': 'Neue Icons in %s hinzufügen. Nutze eine 24×24-viewBox, strichbasierte Pfade ohne Füllungen — die Komponente übernimmt die Farbe per %s. Gib %s für Screenreader an; weglassen bei dekorativen Icons.',
'ds.icons.group.commerce': 'Commerce',
'ds.icons.group.navigation': 'Navigation',
'ds.icons.group.actions': 'Aktionen',
'ds.icons.group.contact': 'Kontakt',
'ds.icons.group.feedback': 'Feedback',
'ds.icons.group.devices': 'Geräte',
// Buttons
'ds.buttons.title': 'Buttons',
'ds.buttons.description': 'Pillenförmig. Pinie-grüner Primary mit warmgelbem Label für die wichtigste Aktion, warmgelber Accent auf Markenflächen, plus Secondary-, Ghost- und Danger-Varianten.',
'ds.buttons.primary': 'Primary',
'ds.buttons.accent': 'Accent',
'ds.buttons.secondary': 'Secondary',
'ds.buttons.ghost': 'Ghost',
'ds.buttons.danger': 'Danger',
'ds.buttons.small': 'Klein',
'ds.buttons.medium': 'Mittel',
'ds.buttons.large': 'Groß',
'ds.buttons.disabled': 'Deaktiviert',
'ds.buttons.loading': 'Lädt',
'ds.buttons.addToCart': 'In den Warenkorb',
'ds.buttons.learnMore': 'Mehr erfahren',
'ds.buttons.save': 'Merken',
'ds.buttons.confirm': 'Bestätigen',
'ds.buttons.continueShopping': 'Weiter einkaufen',
// Badges
'ds.badges.title': 'Badges',
'ds.badges.description': 'Kleine Großbuchstaben-Labels für Metadaten, Status und Eyebrows über Überschriften.',
'ds.badges.neutral': 'Neutral',
'ds.badges.brand': 'Marke',
'ds.badges.accent': 'Akzent',
'ds.badges.subtle': 'Dezent',
'ds.badges.success': 'Erfolg',
'ds.badges.warning': 'Warnung',
'ds.badges.danger': 'Fehler',
'ds.badges.newRelease': 'Neu veröffentlicht',
'ds.badges.featured': 'Empfohlen',
// Inputs
'ds.inputs.title': 'Eingabefelder',
'ds.inputs.description': 'Papier-Fläche mit dünnem grün getöntem Rahmen. Großbuchstaben-Labels. Der Fokus vertieft den Rahmen zu Markengrün.',
'ds.inputs.email': 'E-Mail',
'ds.inputs.emailHint': 'Wir teilen dies nie.',
'ds.inputs.password': 'Passwort',
'ds.inputs.required': 'Pflichtfeld',
'ds.inputs.requiredError': 'Dieses Feld ist erforderlich',
'ds.inputs.disabled': 'Deaktiviert',
'ds.inputs.disabledPlaceholder': 'Hier geht nichts',
// Cards
'ds.cards.title': 'Karten',
'ds.cards.description': 'Flächen zum Gruppieren von Inhalten. Drei Töne — Papier, Creme, Marke — und ein optionales interaktives Anheben beim Hover.',
'ds.cards.paper': 'Papier',
'ds.cards.cream': 'Creme',
'ds.cards.brand': 'Marke',
'ds.cards.paperTitle': 'Standardfläche',
'ds.cards.paperBody': 'Die erste Wahl für die meisten Inhalte. Hoher Kontrast auf dem cremefarbenen Seitenhintergrund.',
'ds.cards.creamTitle': 'Warme Fläche',
'ds.cards.creamBody': 'Eine weichere Alternative für sekundäre Bereiche oder Callouts.',
'ds.cards.brandTitle': 'Dunkle Fläche',
'ds.cards.brandBody': 'Für Feature-Panels, CTAs und Momente, die auffallen wollen.',
'ds.cards.hoverMe': 'Hover mich',
'ds.cards.hoverMeToo': 'Mich auch',
'ds.cards.hoverBody': 'Hebt sich beim Hover mit weichem grün getönten Schatten. Für klickbare Items im Raster.',
'ds.cards.hoverBodyAlt': 'Gleiches Verhalten auf der warmen Fläche.',
'ds.cards.mediaTitle': 'Media-Karte',
'ds.cards.mediaBody': 'Wenn die Karte ein randloses Bild oder einen Header braucht, übergib',
// Product card
'ds.product.title': 'Produktkarte',
'ds.product.description': 'Zusammengesetzt aus Karte, Badge, Button und Icon. Cutout-Bild auf einer Creme-Medienfläche, Titel mit Größe, Preis in der Markenfarbe und ein blockweiter „In den Warenkorb"-Primary.',
'ds.product.outOfStock': 'Nicht verfügbar',
'ds.product.added': 'Hinzugefügt',
// Navbar section
'ds.navbar.title': 'Navigation',
'ds.navbar.description': 'Logo + Nav + Warenkorb, in drei Flächentönen. Auf Mobilgeräten behält die obere Leiste das Logo, Menü + Warenkorb wechseln in ein ergonomisches bodennahes rechtes Floating-Cluster. Das Menü öffnet ein vollflächiges markengrünes Overlay.',
'ds.navbar.tone': 'Navigation-Ton',
'ds.navbar.tone.paper': 'Papier',
'ds.navbar.tone.cream': 'Creme',
'ds.navbar.tone.brand': 'Marke',
'ds.navbar.layout': 'Layout',
'ds.navbar.layout.standard': 'Standard',
'ds.navbar.layout.floating': 'Schwebend',
// Language switcher section
'ds.language.title': 'Sprachwahl',
'ds.language.description': 'Kompakte Pille zum Umschalten zwischen DE, AT und EN. Zwei Töne — Papier für helle Flächen, Marke für das Markengrün — plus ein schwebender Modus, der sich oben rechts an die Ecke heftet.',
'ds.language.paperNote': 'Standardton für helle UI-Flächen wie Karten oder Toolbars.',
'ds.language.creamNote': 'Stärkerer Rahmen, damit die Pille auf warmen Cremflächen Definition behält.',
'ds.language.brandNote': 'Für Einsätze auf dem Markengrün — z. B. im mobilen Menü-Overlay.',
'ds.language.floating': 'Schwebend',
'ds.language.floatingNote': 'Mit dem Prop `floating` dockt die Pille rechts oben im Viewport an (24px Abstand). Genau so wird sie im Design-System oben rechts eingesetzt.',
}
// Austrian German (Österreichisches Deutsch) overrides.
// For technical UI copy, AT ≈ DE — most UI terms are identical in formal writing.
// The authentic distinctions cluster around: shopping vocabulary, "retour" (← zurück),
// and a handful of workplace/web terms. Dialect forms (Wienerisch etc.) are intentionally
// avoided here since the brand's formal voice is standard Austrian, not dialect.
const atOverrides = {
// Shopping — the clearest DE/AT split
'cart.open': 'Einkaufstasche öffnen',
'cart.label': 'Einkaufstasche',
'ds.buttons.addToCart': 'In die Einkaufstasche',
'ds.buttons.continueShopping': 'Weiter einkaufen gehen',
'ds.buttons.save': 'Vormerken',
// "Retour" is strongly associated with Austrian commerce/admin usage
'ds.sidebar.back': '← Retour zur Seite',
// Month/date terms would go here (Jänner/Feber) if we had any — kept for future use
// No-op strings that stay identical to DE are intentionally omitted: redundancy hurts
// clarity and hides the places where AT genuinely differs.
}
const en = {
'nav.shop': 'Shop',
'nav.applications': 'Uses',
'nav.about': 'About',
'nav.contact': 'Contact',
'cart.open': 'Open cart',
'cart.label': 'Cart',
'menu.open': 'Open menu',
'menu.close': 'Close menu',
'language.label': 'Language',
'ds.sidebar.brand': 'Brand',
'ds.sidebar.tokens': 'Tokens',
'ds.sidebar.components': 'Components',
'ds.sidebar.back': '← Back to site',
'ds.nav.logo': 'Logo',
'ds.nav.colors': 'Colors',
'ds.nav.typography': 'Typography',
'ds.nav.radii': 'Radii',
'ds.nav.shadows': 'Shadows',
'ds.nav.icons': 'Icons',
'ds.nav.buttons': 'Buttons',
'ds.nav.badges': 'Badges',
'ds.nav.inputs': 'Inputs',
'ds.nav.cards': 'Cards',
'ds.nav.products': 'Product cards',
'ds.nav.navbar': 'Navbar',
'ds.nav.language': 'Language',
'ds.eyebrow.brand': 'Brand',
'ds.eyebrow.tokens': 'Tokens',
'ds.eyebrow.components': 'Components',
'ds.eyebrow.designSystem': 'Design system',
'ds.heading.variants': 'Variants',
'ds.heading.sizes': 'Sizes',
'ds.heading.states': 'States',
'ds.heading.withIcons': 'With icons',
'ds.heading.onBrandSurface': 'On brand surface',
'ds.heading.block': 'Block',
'ds.heading.usage': 'Usage',
'ds.heading.default': 'Default',
'ds.heading.tones': 'Tones',
'ds.heading.interactive': 'Interactive',
'ds.heading.withoutPadding': 'Without padding',
'ds.heading.scale': 'Scale',
'ds.heading.display': 'Display',
'ds.heading.body': 'Body',
'ds.heading.nonUppercase': 'Non-uppercase',
'ds.heading.onDifferentSurfaces': 'On different surfaces',
'ds.heading.greenOnPaper': 'Green on paper',
'ds.heading.creamOnBrand': 'Cream on brand green',
'ds.logo.title': 'Logo',
'ds.logo.description': 'A single SVG component that inherits its fill from CSS `color`. Use the green wordmark on light surfaces and the cream wordmark on the brand green.',
'ds.logo.usageIntro': 'Use color utilities to set the fill:',
'ds.colors.title': 'Colors',
'ds.colors.description': 'Pine green on cream, with a warm yellow accent. All UI color flows from these tokens — never hand-pick values in components.',
'ds.colors.group.brand': 'Brand',
'ds.colors.group.accent': 'Accent',
'ds.colors.group.surface': 'Surface',
'ds.colors.group.ink': 'Ink',
'ds.colors.group.line': 'Line',
'ds.colors.group.semantic': 'Semantic',
'ds.typography.title': 'Typography',
'ds.typography.description': 'Fraunces for display — warm, organic, optical-sized. DM Sans for body and UI — clean and geometric.',
'ds.typography.serifDesc': 'Serif with optical sizing. Use for hero, section titles, product names.',
'ds.typography.sansDesc': 'Clean geometric sans. Use for body, UI, navigation, labels.',
'ds.typography.sample': 'The quick brown fox',
'ds.radii.title': 'Radii',
'ds.radii.description': "From subtle 6px rounding on small elements to full pills on buttons. Matches the reference site's soft, organic feel.",
'ds.shadows.title': 'Shadows',
'ds.shadows.description': 'All shadows are tinted green rather than neutral black — they feel warm and part of the palette.',
'ds.shadows.sm.note': 'Subtle — nav on scroll, resting cards',
'ds.shadows.md.note': 'Medium — primary button hover',
'ds.shadows.lg.note': 'Large — floating cards, overlays',
'ds.icons.title': 'Icons',
'ds.icons.description': 'A curated stroke-based icon set on a 24x24 grid. Rendered via the Icon component — inherits color from currentColor.',
'ds.icons.search': 'Search icons…',
'ds.icons.copyHint': 'Click a tile to copy its snippet.',
'ds.icons.copied': 'Copied!',
'ds.icons.noMatch': 'No icons match',
'ds.icons.sizeDefault': '20 (default)',
'ds.icons.addHint': 'Add new icons in %s. Use a 24×24 viewBox, stroke-based paths, no fills — the component handles color via %s. Pass %s for screen readers; omit it for decorative icons.',
'ds.icons.group.commerce': 'Commerce',
'ds.icons.group.navigation': 'Navigation',
'ds.icons.group.actions': 'Actions',
'ds.icons.group.contact': 'Contact',
'ds.icons.group.feedback': 'Feedback',
'ds.icons.group.devices': 'Devices',
'ds.buttons.title': 'Buttons',
'ds.buttons.description': 'Pill-shaped. Pine-green primary with warm-yellow label for the main call to action, warm-yellow accent on brand surfaces, plus secondary, ghost, and danger variants.',
'ds.buttons.primary': 'Primary',
'ds.buttons.accent': 'Accent',
'ds.buttons.secondary': 'Secondary',
'ds.buttons.ghost': 'Ghost',
'ds.buttons.danger': 'Danger',
'ds.buttons.small': 'Small',
'ds.buttons.medium': 'Medium',
'ds.buttons.large': 'Large',
'ds.buttons.disabled': 'Disabled',
'ds.buttons.loading': 'Loading',
'ds.buttons.addToCart': 'Add to cart',
'ds.buttons.learnMore': 'Learn more',
'ds.buttons.save': 'Save',
'ds.buttons.confirm': 'Confirm',
'ds.buttons.continueShopping': 'Continue shopping',
'ds.badges.title': 'Badges',
'ds.badges.description': 'Small uppercase labels for metadata, status, and eyebrows above headings.',
'ds.badges.neutral': 'Neutral',
'ds.badges.brand': 'Brand',
'ds.badges.accent': 'Accent',
'ds.badges.subtle': 'Subtle',
'ds.badges.success': 'Success',
'ds.badges.warning': 'Warning',
'ds.badges.danger': 'Danger',
'ds.badges.newRelease': 'New release',
'ds.badges.featured': 'Featured',
'ds.inputs.title': 'Inputs',
'ds.inputs.description': 'Paper surface with a thin green-tinted border. Uppercase eyebrow labels. Focus deepens the border to brand green.',
'ds.inputs.email': 'Email',
'ds.inputs.emailHint': 'We never share this.',
'ds.inputs.password': 'Password',
'ds.inputs.required': 'Required field',
'ds.inputs.requiredError': 'This field is required',
'ds.inputs.disabled': 'Disabled',
'ds.inputs.disabledPlaceholder': "Can't type here",
'ds.cards.title': 'Cards',
'ds.cards.description': 'Surfaces for grouping content. Three tones — paper, cream, brand — and an optional interactive lift on hover.',
'ds.cards.paper': 'Paper',
'ds.cards.cream': 'Cream',
'ds.cards.brand': 'Brand',
'ds.cards.paperTitle': 'Default surface',
'ds.cards.paperBody': 'The go-to card for most content. High contrast on the cream page background.',
'ds.cards.creamTitle': 'Warm surface',
'ds.cards.creamBody': 'A softer alternative for secondary sections or callouts.',
'ds.cards.brandTitle': 'Dark surface',
'ds.cards.brandBody': 'For feature panels, CTAs, and moments that want to stand out.',
'ds.cards.hoverMe': 'Hover me',
'ds.cards.hoverMeToo': 'Hover me too',
'ds.cards.hoverBody': 'Lifts on hover with a soft green-tinted shadow. Use for clickable items in a grid.',
'ds.cards.hoverBodyAlt': 'Same behavior on the warm surface.',
'ds.cards.mediaTitle': 'Media card',
'ds.cards.mediaBody': 'When the card needs a full-bleed image or header, pass',
'ds.product.title': 'Product card',
'ds.product.description': 'Composed from Card, Badge, Button, and Icon. Cutout image on a cream media surface, title + size, price in brand, and a full-width primary "Add to cart".',
'ds.product.outOfStock': 'Out of stock',
'ds.product.added': 'Added',
'ds.navbar.title': 'Navbar',
'ds.navbar.description': 'Logo + nav + cart, in three surface tones. On mobile, the top bar keeps the logo and the menu + cart move to an ergonomic bottom-right floating cluster. The menu opens a full-screen brand-green overlay.',
'ds.navbar.tone': 'Navbar tone',
'ds.navbar.tone.paper': 'Paper',
'ds.navbar.tone.cream': 'Cream',
'ds.navbar.tone.brand': 'Brand',
'ds.navbar.layout': 'Layout',
'ds.navbar.layout.standard': 'Standard',
'ds.navbar.layout.floating': 'Floating',
'ds.language.title': 'Language switcher',
'ds.language.description': 'Compact pill for switching between DE, AT, and EN. Two tones — paper for light surfaces, brand for the brand-green — plus a floating mode that pins to the top-right corner.',
'ds.language.paperNote': 'Default tone for light UI surfaces like cards or toolbars.',
'ds.language.creamNote': 'Stronger border so the pill keeps definition on warm cream surfaces.',
'ds.language.brandNote': 'For use on the brand green — e.g. inside the mobile menu overlay.',
'ds.language.floating': 'Floating',
'ds.language.floatingNote': 'The `floating` prop docks the pill to the top-right of the viewport (24px offset). This is the exact placement used by the design system chrome.',
}
export default {
de,
at: { ...de, ...atOverrides },
en,
}

View File

@@ -1,23 +1,28 @@
<script setup>
import { RouterLink } from 'vue-router'
import Navbar from '@/design-system/components/Navbar.vue'
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>
<div class="min-h-screen bg-surface">
<Navbar variant="brand" layout="floating" :cart-count="0" />
<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-brand-soft">first</em>.
</h1>
<p class="mt-5 text-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>
</div>
</template>

View File

@@ -2,35 +2,38 @@
import SectionShell from './SectionShell.vue'
import Badge from '@/design-system/components/Badge.vue'
import Card from '@/design-system/components/Card.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
</script>
<template>
<SectionShell
eyebrow="Components"
title="Badges"
description="Small uppercase labels for metadata, status, and eyebrows above headings."
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.badges.title')"
:description="t('ds.badges.description')"
>
<section>
<h2 class="eyebrow mb-5">Variants</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.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>
<Badge variant="neutral">{{ t('ds.badges.neutral') }}</Badge>
<Badge variant="brand">{{ t('ds.badges.brand') }}</Badge>
<Badge variant="accent">{{ t('ds.badges.accent') }}</Badge>
<Badge variant="subtle">{{ t('ds.badges.subtle') }}</Badge>
<Badge variant="success">{{ t('ds.badges.success') }}</Badge>
<Badge variant="warning">{{ t('ds.badges.warning') }}</Badge>
<Badge variant="danger">{{ t('ds.badges.danger') }}</Badge>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">Non-uppercase</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.nonUppercase') }}</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="brand" :uppercase="false">{{ t('ds.badges.newRelease') }}</Badge>
<Badge variant="accent" :uppercase="false">{{ t('ds.badges.featured') }}</Badge>
<Badge variant="subtle" :uppercase="false">v2.1.0</Badge>
</div>
</Card>

View File

@@ -2,55 +2,136 @@
import SectionShell from './SectionShell.vue'
import Button from '@/design-system/components/Button.vue'
import Card from '@/design-system/components/Card.vue'
import Icon from '@/design-system/components/Icon.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
</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."
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.buttons.title')"
:description="t('ds.buttons.description')"
>
<section>
<h2 class="eyebrow mb-5">Variants</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.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>
<Button variant="primary">{{ t('ds.buttons.primary') }}</Button>
<Button variant="accent">{{ t('ds.buttons.accent') }}</Button>
<Button variant="secondary">{{ t('ds.buttons.secondary') }}</Button>
<Button variant="ghost">{{ t('ds.buttons.ghost') }}</Button>
<Button variant="danger">{{ t('ds.buttons.danger') }}</Button>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">Sizes</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.onDifferentSurfaces') }}</h2>
<div class="grid md:grid-cols-3 gap-4">
<Card tone="paper">
<p class="eyebrow mb-4">{{ t('ds.cards.paper') }}</p>
<div class="flex flex-wrap gap-3">
<Button variant="primary">
<template #before><Icon name="cart" :size="18" /></template>
{{ t('ds.buttons.addToCart') }}
</Button>
<Button variant="ghost">{{ t('ds.buttons.learnMore') }}</Button>
</div>
</Card>
<Card tone="cream">
<p class="eyebrow mb-4">{{ t('ds.cards.cream') }}</p>
<div class="flex flex-wrap gap-3">
<Button variant="primary">
<template #before><Icon name="cart" :size="18" /></template>
{{ t('ds.buttons.addToCart') }}
</Button>
<Button variant="ghost">{{ t('ds.buttons.learnMore') }}</Button>
</div>
</Card>
<Card tone="brand">
<p class="eyebrow mb-4 !text-cream opacity-80">{{ t('ds.cards.brand') }}</p>
<div class="flex flex-wrap gap-3">
<Button variant="accent">
<template #before><Icon name="cart" :size="18" /></template>
{{ t('ds.buttons.addToCart') }}
</Button>
<Button variant="ghost" class="!text-cream hover:!bg-cream-wash">
{{ t('ds.buttons.learnMore') }}
</Button>
</div>
</Card>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.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>
<Button size="sm">{{ t('ds.buttons.small') }}</Button>
<Button size="md">{{ t('ds.buttons.medium') }}</Button>
<Button size="lg">{{ t('ds.buttons.large') }}</Button>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">States</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.withIcons') }}</h2>
<Card tone="paper">
<div class="flex flex-wrap items-center gap-3">
<Button variant="primary">
<template #before><Icon name="cart" :size="18" /></template>
{{ t('ds.buttons.addToCart') }}
</Button>
<Button variant="secondary">
{{ t('ds.buttons.learnMore') }}
<template #after><Icon name="arrow-right" :size="18" /></template>
</Button>
<Button variant="ghost">
<template #before><Icon name="heart" :size="18" /></template>
{{ t('ds.buttons.save') }}
</Button>
<Button variant="accent" size="sm">
<template #before><Icon name="check" :size="16" /></template>
{{ t('ds.buttons.confirm') }}
</Button>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.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>
<Button>{{ t('ds.heading.default') }}</Button>
<Button disabled>{{ t('ds.buttons.disabled') }}</Button>
<Button loading>{{ t('ds.buttons.loading') }}</Button>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">Block</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.block') }}</h2>
<Card tone="paper">
<div class="max-w-md">
<Button block variant="primary">Full-width primary</Button>
<div class="max-w-md space-y-3">
<Button block variant="primary">
<template #before><Icon name="cart" :size="18" /></template>
{{ t('ds.buttons.addToCart') }}
</Button>
<Button block variant="secondary">{{ t('ds.buttons.continueShopping') }}</Button>
</div>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.usage') }}</h2>
<div class="rounded-md border border-line bg-paper p-6 font-mono text-[12px] text-ink">
<pre class="whitespace-pre-wrap">&lt;Button variant="primary" size="md"&gt;
&lt;template #before&gt;&lt;Icon name="cart" :size="18" /&gt;&lt;/template&gt;
{{ t('ds.buttons.addToCart') }}
&lt;/Button&gt;</pre>
</div>
</section>
</SectionShell>
</template>

View File

@@ -2,67 +2,60 @@
import SectionShell from './SectionShell.vue'
import Card from '@/design-system/components/Card.vue'
import Badge from '@/design-system/components/Badge.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
</script>
<template>
<SectionShell
eyebrow="Components"
title="Cards"
description="Surfaces for grouping content. Three tones — paper, cream, brand — and an optional interactive lift on hover."
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.cards.title')"
:description="t('ds.cards.description')"
>
<section>
<h2 class="eyebrow mb-5">Tones</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.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>
<Badge variant="subtle" class="mb-4">{{ t('ds.cards.paper') }}</Badge>
<h3 class="font-display text-2xl font-normal mb-2">{{ t('ds.cards.paperTitle') }}</h3>
<p class="text-[14px] text-muted leading-relaxed">{{ t('ds.cards.paperBody') }}</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>
<Badge variant="subtle" class="mb-4">{{ t('ds.cards.cream') }}</Badge>
<h3 class="font-display text-2xl font-normal mb-2">{{ t('ds.cards.creamTitle') }}</h3>
<p class="text-[14px] text-muted leading-relaxed">{{ t('ds.cards.creamBody') }}</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>
<Badge variant="accent" class="mb-4">{{ t('ds.cards.brand') }}</Badge>
<h3 class="font-display text-2xl font-normal mb-2">{{ t('ds.cards.brandTitle') }}</h3>
<p class="text-[14px] opacity-80 leading-relaxed">{{ t('ds.cards.brandBody') }}</p>
</Card>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">Interactive</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.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>
<h3 class="font-display text-2xl font-normal mb-2">{{ t('ds.cards.hoverMe') }}</h3>
<p class="text-[14px] text-muted leading-relaxed">{{ t('ds.cards.hoverBody') }}</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>
<h3 class="font-display text-2xl font-normal mb-2">{{ t('ds.cards.hoverMeToo') }}</h3>
<p class="text-[14px] text-muted leading-relaxed">{{ t('ds.cards.hoverBodyAlt') }}</p>
</Card>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">Without padding</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.withoutPadding') }}</h2>
<Card tone="paper" :padded="false">
<div class="h-40 bg-[var(--color-cream)] rounded-t-[var(--radius-md)]" />
<div class="h-40 bg-cream rounded-t-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>.
<h3 class="font-display text-2xl font-normal mb-2">{{ t('ds.cards.mediaTitle') }}</h3>
<p class="text-[14px] text-muted leading-relaxed">
{{ t('ds.cards.mediaBody') }} <code class="font-mono text-[12px]">:padded="false"</code>.
</p>
</div>
</Card>

View File

@@ -1,21 +1,25 @@
<script setup>
import { computed } from 'vue'
import SectionShell from './SectionShell.vue'
import { useI18n } from '@/i18n/index.js'
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'] },
]
const { t } = useI18n()
const groups = computed(() => [
{ title: t('ds.colors.group.brand'), names: ['brand', 'brand-hover', 'brand-soft'] },
{ title: t('ds.colors.group.accent'), names: ['accent', 'accent-soft', 'accent-ink'] },
{ title: t('ds.colors.group.surface'), names: ['surface', 'paper', 'cream'] },
{ title: t('ds.colors.group.ink'), names: ['ink', 'muted'] },
{ title: t('ds.colors.group.line'), names: ['line', 'line-strong'] },
{ title: t('ds.colors.group.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."
:eyebrow="t('ds.eyebrow.tokens')"
:title="t('ds.colors.title')"
:description="t('ds.colors.description')"
>
<section v-for="group in groups" :key="group.title">
<h2 class="eyebrow mb-5">{{ group.title }}</h2>
@@ -23,14 +27,14 @@ const groups = [
<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)]"
class="rounded-md border border-line overflow-hidden bg-paper"
>
<div
class="h-28 border-b border-[var(--color-line)]"
class="h-28 border-b border-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>
<code class="font-mono text-[12px] text-ink block">--color-{{ name }}</code>
</div>
</div>
</div>

View File

@@ -1,37 +1,53 @@
<script setup>
import { computed } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import Logo from '@/design-system/components/Logo.vue'
import LanguageSwitcher from '@/design-system/components/LanguageSwitcher.vue'
import { useI18n } from '@/i18n/index.js'
const groups = [
const { t } = useI18n()
const groups = computed(() => [
{
title: 'Tokens',
title: t('ds.sidebar.brand'),
items: [
{ name: 'ds-colors', label: 'Colors' },
{ name: 'ds-typography', label: 'Typography' },
{ name: 'ds-radii', label: 'Radii' },
{ name: 'ds-shadows', label: 'Shadows' },
{ name: 'ds-logo', label: t('ds.nav.logo') },
],
},
{
title: 'Components',
title: t('ds.sidebar.tokens'),
items: [
{ name: 'ds-buttons', label: 'Buttons' },
{ name: 'ds-badges', label: 'Badges' },
{ name: 'ds-inputs', label: 'Inputs' },
{ name: 'ds-cards', label: 'Cards' },
{ name: 'ds-colors', label: t('ds.nav.colors') },
{ name: 'ds-typography', label: t('ds.nav.typography') },
{ name: 'ds-radii', label: t('ds.nav.radii') },
{ name: 'ds-shadows', label: t('ds.nav.shadows') },
],
},
]
{
title: t('ds.sidebar.components'),
items: [
{ name: 'ds-icons', label: t('ds.nav.icons') },
{ name: 'ds-buttons', label: t('ds.nav.buttons') },
{ name: 'ds-badges', label: t('ds.nav.badges') },
{ name: 'ds-inputs', label: t('ds.nav.inputs') },
{ name: 'ds-cards', label: t('ds.nav.cards') },
{ name: 'ds-products', label: t('ds.nav.products') },
{ name: 'ds-navbar', label: t('ds.nav.navbar') },
{ name: 'ds-language', label: t('ds.nav.language') },
],
},
])
</script>
<template>
<div class="h-screen flex bg-[#fafafa] text-[var(--color-ink)] overflow-hidden">
<div class="h-screen flex bg-surface text-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>
<aside class="w-[260px] shrink-0 border-r border-line bg-paper flex flex-col">
<div class="px-6 py-6 border-b border-line">
<RouterLink to="/" class="block text-brand" aria-label="Kaiser Natron home">
<Logo class="w-16 h-auto" />
</RouterLink>
<p class="eyebrow mt-2">Design system</p>
<p class="eyebrow mt-3">{{ t('ds.eyebrow.designSystem') }}</p>
</div>
<nav class="flex-1 overflow-y-auto px-3 py-5 space-y-6">
@@ -42,8 +58,8 @@ const groups = [
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)]"
class="px-3 py-2 rounded-sm text-[14px] font-medium text-muted hover:text-brand hover:bg-brand-wash transition-colors"
active-class="!text-brand !bg-brand-soft-wash"
>
{{ item.label }}
</RouterLink>
@@ -51,12 +67,12 @@ const groups = [
</div>
</nav>
<div class="px-6 py-4 border-t border-[var(--color-line)]">
<div class="px-6 py-4 border-t border-line">
<RouterLink
to="/"
class="text-[13px] text-[var(--color-muted)] hover:text-[var(--color-brand)] transition-colors"
class="text-[13px] text-muted hover:text-brand transition-colors"
>
Back to site
{{ t('ds.sidebar.back') }}
</RouterLink>
</div>
</aside>
@@ -65,5 +81,8 @@ const groups = [
<main class="flex-1 overflow-y-auto">
<RouterView />
</main>
<!-- Global language switcher, floating top-right -->
<LanguageSwitcher floating />
</div>
</template>

View File

@@ -0,0 +1,148 @@
<script setup>
import { computed, ref } from 'vue'
import SectionShell from './SectionShell.vue'
import Icon from '@/design-system/components/Icon.vue'
import { icons } from '@/design-system/components/icons.js'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
const groups = computed(() => [
{ title: t('ds.icons.group.commerce'), names: ['cart', 'bag', 'heart', 'user', 'search'] },
{ title: t('ds.icons.group.navigation'), names: ['menu', 'close', 'chevron-left', 'chevron-right', 'chevron-down', 'chevron-up', 'arrow-left', 'arrow-right'] },
{ title: t('ds.icons.group.actions'), names: ['plus', 'minus', 'check'] },
{ title: t('ds.icons.group.contact'), names: ['mail', 'phone', 'map-pin', 'external-link'] },
{ title: t('ds.icons.group.feedback'), names: ['info', 'star'] },
{ title: t('ds.icons.group.devices'), names: ['mobile', 'tablet', 'desktop'] },
])
const query = ref('')
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return groups.value
return groups.value
.map((g) => ({
...g,
names: g.names.filter((n) => n.includes(q) || (icons[n]?.label || '').toLowerCase().includes(q)),
}))
.filter((g) => g.names.length > 0)
})
const copied = ref('')
async function copy(name) {
const snippet = `<Icon name="${name}" />`
try {
await navigator.clipboard.writeText(snippet)
copied.value = name
setTimeout(() => {
if (copied.value === name) copied.value = ''
}, 1200)
} catch {
// clipboard blocked; ignore
}
}
</script>
<template>
<SectionShell
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.icons.title')"
:description="t('ds.icons.description')"
>
<section>
<div class="mb-5 flex items-center gap-3 flex-wrap">
<label class="relative inline-flex items-center">
<span class="absolute left-3 text-muted pointer-events-none">
<Icon name="search" :size="16" />
</span>
<input
v-model="query"
type="search"
:placeholder="t('ds.icons.search')"
class="pl-9 pr-4 py-2 rounded-pill border border-line bg-paper text-[14px] text-ink placeholder:text-muted focus:outline-none focus:border-brand w-[260px]"
/>
</label>
<span class="text-[12px] text-muted">{{ t('ds.icons.copyHint') }}</span>
</div>
<div v-if="filtered.length === 0" class="text-[14px] text-muted">
{{ t('ds.icons.noMatch') }} "{{ query }}".
</div>
<div v-for="group in filtered" :key="group.title" class="mb-10 last:mb-0">
<h2 class="eyebrow mb-4">{{ group.title }}</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
<button
v-for="name in group.names"
:key="name"
type="button"
class="group flex flex-col items-center justify-center gap-2 p-4 rounded-md border border-line bg-paper text-ink hover:border-brand-soft hover:-translate-y-0.5 hover:shadow-sm transition-all duration-base ease-out"
@click="copy(name)"
>
<span class="w-10 h-10 rounded-sm bg-cream text-brand flex items-center justify-center">
<Icon :name="name" :size="22" />
</span>
<span class="font-mono text-[11px] text-muted group-hover:text-brand transition-colors">
{{ copied === name ? t('ds.icons.copied') : name }}
</span>
</button>
</div>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.onDifferentSurfaces') }}</h2>
<div class="grid md:grid-cols-3 gap-4">
<div class="rounded-md border border-line bg-paper text-brand p-8 flex items-center justify-center gap-6">
<Icon name="cart" :size="28" />
<Icon name="heart" :size="28" />
<Icon name="user" :size="28" />
</div>
<div class="rounded-md border border-line bg-cream text-brand p-8 flex items-center justify-center gap-6">
<Icon name="cart" :size="28" />
<Icon name="heart" :size="28" />
<Icon name="user" :size="28" />
</div>
<div class="rounded-md bg-brand text-accent p-8 flex items-center justify-center gap-6">
<Icon name="cart" :size="28" />
<Icon name="heart" :size="28" />
<Icon name="user" :size="28" />
</div>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.sizes') }}</h2>
<div class="rounded-md border border-line bg-paper p-8 flex items-end gap-8 text-brand">
<div class="flex flex-col items-center gap-3">
<Icon name="cart" :size="16" />
<code class="font-mono text-[11px] text-muted">16</code>
</div>
<div class="flex flex-col items-center gap-3">
<Icon name="cart" :size="20" />
<code class="font-mono text-[11px] text-muted">{{ t('ds.icons.sizeDefault') }}</code>
</div>
<div class="flex flex-col items-center gap-3">
<Icon name="cart" :size="24" />
<code class="font-mono text-[11px] text-muted">24</code>
</div>
<div class="flex flex-col items-center gap-3">
<Icon name="cart" :size="32" />
<code class="font-mono text-[11px] text-muted">32</code>
</div>
<div class="flex flex-col items-center gap-3">
<Icon name="cart" :size="48" />
<code class="font-mono text-[11px] text-muted">48</code>
</div>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.usage') }}</h2>
<div class="rounded-md border border-line bg-paper p-6 font-mono text-[12px] text-ink">
<pre class="whitespace-pre-wrap">&lt;Icon name="cart" :size="20" /&gt;
&lt;Icon name="arrow-right" :size="16" label="Next slide" /&gt;</pre>
</div>
</section>
</SectionShell>
</template>

View File

@@ -3,7 +3,9 @@ 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'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
const email = ref('')
const password = ref('')
const required = ref('')
@@ -11,24 +13,24 @@ const required = ref('')
<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."
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.inputs.title')"
:description="t('ds.inputs.description')"
>
<section>
<h2 class="eyebrow mb-5">Default</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.default') }}</h2>
<Card tone="paper">
<div class="grid md:grid-cols-2 gap-6 max-w-3xl">
<Input
v-model="email"
label="Email"
:label="t('ds.inputs.email')"
type="email"
placeholder="you@example.com"
hint="We never share this."
:hint="t('ds.inputs.emailHint')"
/>
<Input
v-model="password"
label="Password"
:label="t('ds.inputs.password')"
type="password"
placeholder="••••••••"
/>
@@ -37,18 +39,18 @@ const required = ref('')
</section>
<section>
<h2 class="eyebrow mb-5">States</h2>
<h2 class="eyebrow mb-5">{{ t('ds.heading.states') }}</h2>
<Card tone="paper">
<div class="grid md:grid-cols-2 gap-6 max-w-3xl">
<Input
v-model="required"
label="Required field"
:label="t('ds.inputs.required')"
required
error="This field is required"
:error="t('ds.inputs.requiredError')"
/>
<Input
label="Disabled"
placeholder="Can't type here"
:label="t('ds.inputs.disabled')"
:placeholder="t('ds.inputs.disabledPlaceholder')"
disabled
/>
</div>

View File

@@ -0,0 +1,61 @@
<script setup>
import SectionShell from './SectionShell.vue'
import Card from '@/design-system/components/Card.vue'
import LanguageSwitcher from '@/design-system/components/LanguageSwitcher.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
</script>
<template>
<SectionShell
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.language.title')"
:description="t('ds.language.description')"
>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.onDifferentSurfaces') }}</h2>
<div class="grid md:grid-cols-3 gap-4">
<Card tone="paper">
<p class="eyebrow mb-4">{{ t('ds.cards.paper') }}</p>
<LanguageSwitcher tone="paper" />
<p class="text-[13px] text-muted mt-4 leading-relaxed">
{{ t('ds.language.paperNote') }}
</p>
</Card>
<Card tone="cream">
<p class="eyebrow mb-4">{{ t('ds.cards.cream') }}</p>
<LanguageSwitcher tone="cream" />
<p class="text-[13px] text-muted mt-4 leading-relaxed">
{{ t('ds.language.creamNote') }}
</p>
</Card>
<Card tone="brand">
<p class="eyebrow mb-4">{{ t('ds.cards.brand') }}</p>
<LanguageSwitcher tone="brand" />
<p class="text-[13px] opacity-80 mt-4 leading-relaxed">
{{ t('ds.language.brandNote') }}
</p>
</Card>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.language.floating') }}</h2>
<Card tone="paper">
<p class="text-[13px] text-muted max-w-2xl leading-relaxed">
{{ t('ds.language.floatingNote') }}
</p>
</Card>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.usage') }}</h2>
<div class="rounded-md border border-line bg-paper p-6 font-mono text-[12px] text-ink">
<pre class="whitespace-pre-wrap">&lt;LanguageSwitcher /&gt;
&lt;LanguageSwitcher tone="brand" /&gt;
&lt;LanguageSwitcher floating /&gt;</pre>
</div>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
import SectionShell from './SectionShell.vue'
import Logo from '@/design-system/components/Logo.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
</script>
<template>
<SectionShell
:eyebrow="t('ds.eyebrow.brand')"
:title="t('ds.logo.title')"
:description="t('ds.logo.description')"
>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.onDifferentSurfaces') }}</h2>
<div class="grid md:grid-cols-3 gap-4">
<div class="rounded-md border border-line bg-paper p-10 flex flex-col items-center gap-4">
<Logo class="w-40 h-auto text-brand" />
<code class="font-mono text-[11px] text-muted">{{ t('ds.cards.paper') }}</code>
</div>
<div class="rounded-md border border-line bg-cream p-10 flex flex-col items-center gap-4">
<Logo class="w-40 h-auto text-brand" />
<code class="font-mono text-[11px] text-muted">{{ t('ds.cards.cream') }}</code>
</div>
<div class="rounded-md bg-brand p-10 flex flex-col items-center gap-4">
<Logo class="w-40 h-auto text-paper" />
<code class="font-mono text-[11px] text-cream opacity-70">{{ t('ds.cards.brand') }}</code>
</div>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.sizes') }}</h2>
<div class="rounded-md border border-line bg-paper p-10 flex items-end gap-10 flex-wrap">
<div class="flex flex-col items-center gap-3">
<Logo class="w-16 h-auto text-brand" />
<code class="font-mono text-[11px] text-muted">w-16</code>
</div>
<div class="flex flex-col items-center gap-3">
<Logo class="w-32 h-auto text-brand" />
<code class="font-mono text-[11px] text-muted">w-32</code>
</div>
<div class="flex flex-col items-center gap-3">
<Logo class="w-60 h-auto text-brand" />
<code class="font-mono text-[11px] text-muted">w-60</code>
</div>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.usage') }}</h2>
<div class="rounded-md border border-line bg-paper p-6 font-mono text-[12px] text-ink">
<p class="mb-2 text-muted">{{ t('ds.logo.usageIntro') }}</p>
<pre class="whitespace-pre-wrap">&lt;Logo class="w-60 h-auto text-brand" /&gt;</pre>
</div>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,96 @@
<script setup>
import { ref, computed } from 'vue'
import SectionShell from './SectionShell.vue'
import DevicePreview from '@/design-system/components/DevicePreview.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
const tones = computed(() => [
{ id: 'paper', label: t('ds.navbar.tone.paper'), swatch: '#ffffff' },
{ id: 'cream', label: t('ds.navbar.tone.cream'), swatch: 'var(--color-cream)' },
{ id: 'brand', label: t('ds.navbar.tone.brand'), swatch: 'var(--color-brand)' },
])
const layouts = computed(() => [
{ id: 'standard', label: t('ds.navbar.layout.standard') },
{ id: 'floating', label: t('ds.navbar.layout.floating') },
])
const tone = ref('paper')
const layout = ref('standard')
const src = computed(
() => `/design/preview/navbar?variant=${tone.value}&layout=${layout.value}`,
)
</script>
<template>
<SectionShell
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.navbar.title')"
:description="t('ds.navbar.description')"
wide
>
<section>
<DevicePreview :src="src" initial="mobile" :height="720">
<template #controls>
<div
role="tablist"
:aria-label="t('ds.navbar.layout')"
class="inline-flex items-center p-1 gap-0.5 rounded-pill border border-line bg-paper"
>
<button
v-for="l in layouts"
:key="l.id"
type="button"
role="tab"
:aria-selected="layout === l.id"
:class="[
'px-3 py-1.5 text-[12px] font-semibold tracking-label rounded-pill transition-colors duration-base',
layout === l.id ? 'bg-brand text-accent' : 'text-muted hover:text-brand',
]"
@click="layout = l.id"
>{{ l.label }}</button>
</div>
<div
role="tablist"
:aria-label="t('ds.navbar.tone')"
class="inline-flex items-center p-1 gap-0.5 rounded-pill border border-line bg-paper"
>
<button
v-for="v in tones"
:key="v.id"
type="button"
role="tab"
:aria-selected="tone === v.id"
:class="[
'inline-flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold tracking-label rounded-pill transition-colors duration-base',
tone === v.id ? 'bg-brand text-accent' : 'text-muted hover:text-brand',
]"
@click="tone = v.id"
>
<span
class="w-2.5 h-2.5 rounded-full border border-line-strong"
:style="{ backgroundColor: v.swatch }"
/>
{{ v.label }}
</button>
</div>
</template>
</DevicePreview>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.usage') }}</h2>
<div class="rounded-md border border-line bg-paper p-6 font-mono text-[12px] text-ink">
<pre class="whitespace-pre-wrap">&lt;Navbar
variant="paper"
layout="floating"
:items="[{ key: 'nav.shop', href: '/shop' }]"
:cart-count="2"
@cart="openCart"
@nav="onNav"
/&gt;</pre>
</div>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,100 @@
<script setup>
import { ref } from 'vue'
import SectionShell from './SectionShell.vue'
import ProductCard from '@/design-system/components/ProductCard.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
const lastAdded = ref('')
function onAdd(name) {
lastAdded.value = name
setTimeout(() => { if (lastAdded.value === name) lastAdded.value = '' }, 2000)
}
const imgPulver250 = '/products/cutouts/kaiser-natron-pulver-250-g-gro%C3%9Fpackung-removebg-preview.png'
</script>
<template>
<SectionShell
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.product.title')"
:description="t('ds.product.description')"
>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.default') }}</h2>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
<ProductCard
title="Kaiser-Natron® Pulver"
size="250 g Großpackung"
:price="4.49"
:image="imgPulver250"
image-alt="Kaiser-Natron Pulver 250 g Großpackung"
href="#"
@add="onAdd('pulver-250')"
/>
<ProductCard
title="Kaiser-Natron® Pulver"
size="250 g Großpackung"
:price="4.49"
:image="imgPulver250"
:badge="t('ds.badges.featured')"
badge-variant="accent"
href="#"
@add="onAdd('pulver-250-featured')"
/>
<ProductCard
title="Kaiser-Natron® Pulver"
size="250 g Großpackung"
:price="4.49"
:image="imgPulver250"
tone="cream"
href="#"
@add="onAdd('pulver-250-cream')"
/>
</div>
<p v-if="lastAdded" class="mt-5 text-sm text-muted">
{{ t('ds.product.added') }}: <code class="font-mono text-[12px]">{{ lastAdded }}</code>
</p>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.states') }}</h2>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
<ProductCard
title="Kaiser-Natron® Pulver"
size="250 g Großpackung"
:price="4.49"
:image="imgPulver250"
:in-stock="false"
/>
<ProductCard
title="Kaiser-Natron® Pulver"
size="250 g Großpackung"
:price="4.49"
:image="imgPulver250"
:badge="t('ds.badges.newRelease')"
badge-variant="brand"
href="#"
/>
</div>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.usage') }}</h2>
<div class="rounded-md border border-line bg-paper p-6 font-mono text-[12px] text-ink">
<pre class="whitespace-pre-wrap">&lt;ProductCard
title="Kaiser-Natron® Pulver"
size="250 g Großpackung"
:price="4.49"
image="/products/cutouts/…-removebg-preview.png"
badge="Bestseller"
badge-variant="accent"
tone="paper"
href="/kaiser-natron-pulver-250-g-grosspackung"
@add="addToCart(sku)"
/&gt;</pre>
</div>
</section>
</SectionShell>
</template>

View File

@@ -1,5 +1,8 @@
<script setup>
import SectionShell from './SectionShell.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
const radii = [
{ name: 'xs', value: '6px' },
@@ -13,18 +16,18 @@ const radii = [
<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."
:eyebrow="t('ds.eyebrow.tokens')"
:title="t('ds.radii.title')"
:description="t('ds.radii.description')"
>
<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)]"
class="h-32 w-full bg-paper border border-line mb-4 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>
<code class="font-mono text-[12px] text-ink block">--radius-{{ r.name }}</code>
<span class="text-[12px] text-muted mt-1">{{ r.value }}</span>
</div>
</div>
</SectionShell>

View File

@@ -3,17 +3,18 @@ defineProps({
eyebrow: { type: String, default: 'Design system' },
title: { type: String, required: true },
description: { type: String, default: '' },
wide: { type: Boolean, default: false },
})
</script>
<template>
<div class="mx-auto max-w-5xl px-10 lg:px-16 py-16">
<div :class="['mx-auto py-16', wide ? 'max-w-none px-6 lg:px-8' : 'max-w-5xl px-10 lg:px-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)]">
<h1 class="font-display text-5xl font-normal tracking-tight leading-[1.05] text-ink">
{{ title }}
</h1>
<p v-if="description" class="mt-5 text-[17px] text-[var(--color-muted)] leading-relaxed">
<p v-if="description" class="mt-5 text-[17px] text-muted leading-relaxed">
{{ description }}
</p>
</header>

View File

@@ -1,28 +1,32 @@
<script setup>
import { computed } from 'vue'
import SectionShell from './SectionShell.vue'
import { useI18n } from '@/i18n/index.js'
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' },
]
const { t } = useI18n()
const shadows = computed(() => [
{ name: 'sm', css: 'var(--shadow-sm)', note: t('ds.shadows.sm.note') },
{ name: 'md', css: 'var(--shadow-md)', note: t('ds.shadows.md.note') },
{ name: 'lg', css: 'var(--shadow-lg)', note: t('ds.shadows.lg.note') },
])
</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."
:eyebrow="t('ds.eyebrow.tokens')"
:title="t('ds.shadows.title')"
:description="t('ds.shadows.description')"
>
<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)]"
class="h-36 rounded-md bg-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>
<code class="font-mono text-[12px] text-ink block">--shadow-{{ s.name }}</code>
<p class="text-[13px] text-muted mt-1">{{ s.note }}</p>
</div>
</div>
</div>

View File

@@ -1,71 +1,40 @@
<script setup>
import SectionShell from './SectionShell.vue'
import Card from '@/design-system/components/Card.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
const scale = ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl']
</script>
<template>
<SectionShell
eyebrow="Tokens"
title="Typography"
description="Fraunces for display — warm, organic, optical-sized. DM Sans for body and UI — clean and geometric."
:eyebrow="t('ds.eyebrow.tokens')"
:title="t('ds.typography.title')"
:description="t('ds.typography.description')"
>
<section class="grid md:grid-cols-2 gap-6">
<Card tone="paper">
<p class="eyebrow mb-3">Display</p>
<p class="eyebrow mb-3">{{ t('ds.heading.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>
<p class="text-[14px] text-muted leading-relaxed">{{ t('ds.typography.serifDesc') }}</p>
<code class="font-mono text-[11px] text-muted block mt-5">var(--font-serif)</code>
</Card>
<Card tone="paper">
<p class="eyebrow mb-3">Body</p>
<p class="eyebrow mb-3">{{ t('ds.heading.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>
<p class="text-[14px] text-muted leading-relaxed">{{ t('ds.typography.sansDesc') }}</p>
<code class="font-mono text-[11px] text-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>
<h2 class="eyebrow mb-6">{{ t('ds.heading.scale') }}</h2>
<div class="divide-y divide-line rounded-md border border-line bg-paper px-6">
<div v-for="size in scale" :key="size" class="flex items-baseline gap-8 py-4">
<code class="font-mono text-[11px] text-muted w-24 shrink-0">text-{{ size }}</code>
<span :class="`text-${size}`">{{ t('ds.typography.sample') }}</span>
</div>
</div>
</section>

View File

@@ -0,0 +1,25 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Navbar from '@/design-system/components/Navbar.vue'
const route = useRoute()
const variant = computed(() => {
const v = route.query.variant
return ['paper', 'cream', 'brand'].includes(v) ? v : 'paper'
})
const layout = computed(() => (route.query.layout === 'floating' ? 'floating' : 'standard'))
</script>
<template>
<div class="min-h-screen bg-surface">
<Navbar :variant="variant" :layout="layout" :cart-count="2" />
<!-- Enough scroll content to demonstrate the sticky-on-scroll behavior. -->
<div class="max-w-5xl mx-auto px-6 py-16 space-y-6">
<div v-for="h in [40, 28, 40, 32, 40, 28, 36, 40]" :key="h"
class="rounded-md border border-line bg-paper"
:style="{ height: h * 4 + 'px' }"
/>
</div>
</div>
</template>

View File

@@ -1,13 +1,25 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'home', component: () => import('@/pages/HomePage.vue') },
{
path: '/',
name: 'home',
component: () => import('@/pages/HomePage.vue'),
meta: { layout: 'none' },
},
{
path: '/design/preview/navbar',
name: 'ds-preview-navbar',
component: () => import('@/pages/design/previews/NavbarPreview.vue'),
meta: { layout: 'none', preview: true },
},
{
path: '/design',
component: () => import('@/pages/design/DesignLayout.vue'),
meta: { layout: 'none' },
children: [
{ path: '', redirect: '/design/colors' },
{ path: '', redirect: '/design/logo' },
{ path: 'logo', name: 'ds-logo', component: () => import('@/pages/design/LogoSection.vue') },
{ 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') },
@@ -16,6 +28,10 @@ const routes = [
{ 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') },
{ path: 'products', name: 'ds-products', component: () => import('@/pages/design/ProductsSection.vue') },
{ path: 'navbar', name: 'ds-navbar', component: () => import('@/pages/design/NavbarSection.vue') },
{ path: 'language', name: 'ds-language', component: () => import('@/pages/design/LanguageSwitcherSection.vue') },
{ path: 'icons', name: 'ds-icons', component: () => import('@/pages/design/IconsSection.vue') },
],
},
]