product cards and containerisation
35
.dockerignore
Normal 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
@@ -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
|
||||
11
README.md
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 |
1
public/logo/logo-kaisernatron.svg
Normal 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 |
|
After Width: | Height: | Size: 900 KiB |
BIN
public/products/gazelle-waeschestaerke-1000-ml-flasche.jpg
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
public/products/gruene-tante-mit-quarzmehl-500-ml-dose.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/products/holste-handwaschpaste-500-ml.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/products/holste-kalk--und-urinsteinloeser-750-ml.jpg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
public/products/holste-reisstaerke-250-g-faltschachtel.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/products/holste-schmierseife-fluessig-1-l-flasche.jpg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/products/holste-wasch-soda-500-g-beutel.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/products/kaiser-natron-allzweck-reiniger-750-ml.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
BIN
public/products/kaiser-natron-allzweck-spray-500-ml.jpg
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
public/products/kaiser-natron-bad-500-g.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
public/products/kaiser-natron-daunenwasch-250-ml.jpg
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
public/products/kaiser-natron-fussbad-500-g.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
BIN
public/products/kaiser-natron-pulver-250-g-großpackung.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB |
BIN
public/products/kaiser-natron-pulver-3.490-g-eimer.jpg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
BIN
public/products/kaiser-natron-pulver-50-g-beutel.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB |
BIN
public/products/kaiser-natron-sport-profi-250-ml.jpg
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
public/products/kaiser-natron-spuelmittel-500-ml.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
BIN
public/products/kaiser-natron-tabletten-100-g-dose.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
BIN
public/products/linda-fleckenweg-200-ml-tube.jpg
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/products/linda-handreiniger-der-kraftvolle-200-g-tube.jpg
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/products/linda-neutral-375-ml-dose.jpg
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
176
scripts/remove-bg.py
Normal 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
@@ -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
|
||||
22
src/App.vue
@@ -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>
|
||||
|
||||
220
src/components/SplashIntro.vue
Normal 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],
|
||||
])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
|
||||
70
src/design-system/components/DevicePreview.vue
Normal 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>
|
||||
33
src/design-system/components/Icon.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
66
src/design-system/components/LanguageSwitcher.vue
Normal 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>
|
||||
33
src/design-system/components/Logo.vue
Normal 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>
|
||||
240
src/design-system/components/Navbar.vue
Normal 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>
|
||||
123
src/design-system/components/ProductCard.vue
Normal 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>
|
||||
85
src/design-system/components/icons.js
Normal 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)
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"><Button variant="primary" size="md">
|
||||
<template #before><Icon name="cart" :size="18" /></template>
|
||||
{{ t('ds.buttons.addToCart') }}
|
||||
</Button></pre>
|
||||
</div>
|
||||
</section>
|
||||
</SectionShell>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
148
src/pages/design/IconsSection.vue
Normal 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"><Icon name="cart" :size="20" />
|
||||
<Icon name="arrow-right" :size="16" label="Next slide" /></pre>
|
||||
</div>
|
||||
</section>
|
||||
</SectionShell>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
61
src/pages/design/LanguageSwitcherSection.vue
Normal 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"><LanguageSwitcher />
|
||||
<LanguageSwitcher tone="brand" />
|
||||
<LanguageSwitcher floating /></pre>
|
||||
</div>
|
||||
</section>
|
||||
</SectionShell>
|
||||
</template>
|
||||
59
src/pages/design/LogoSection.vue
Normal 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"><Logo class="w-60 h-auto text-brand" /></pre>
|
||||
</div>
|
||||
</section>
|
||||
</SectionShell>
|
||||
</template>
|
||||
96
src/pages/design/NavbarSection.vue
Normal 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"><Navbar
|
||||
variant="paper"
|
||||
layout="floating"
|
||||
:items="[{ key: 'nav.shop', href: '/shop' }]"
|
||||
:cart-count="2"
|
||||
@cart="openCart"
|
||||
@nav="onNav"
|
||||
/></pre>
|
||||
</div>
|
||||
</section>
|
||||
</SectionShell>
|
||||
</template>
|
||||
100
src/pages/design/ProductsSection.vue
Normal 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"><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)"
|
||||
/></pre>
|
||||
</div>
|
||||
</section>
|
||||
</SectionShell>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
25
src/pages/design/previews/NavbarPreview.vue
Normal 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>
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||