diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a7c16ce --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c41622 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index cebbd5d..f1d31d0 100644 --- a/README.md +++ b/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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aa65c62 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..077d701 --- /dev/null +++ b/nginx.conf @@ -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-.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; + } +} diff --git a/package.json b/package.json index e4a2e9e..8021fd2 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/favicon.svg b/public/favicon.svg index 2e62617..9244750 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1 @@ -K +K diff --git a/public/logo/logo-kaisernatron.svg b/public/logo/logo-kaisernatron.svg new file mode 100644 index 0000000..311444a --- /dev/null +++ b/public/logo/logo-kaisernatron.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/products/cutouts/kaiser-natron-pulver-250-g-großpackung-removebg-preview.png b/public/products/cutouts/kaiser-natron-pulver-250-g-großpackung-removebg-preview.png new file mode 100644 index 0000000..192c438 Binary files /dev/null and b/public/products/cutouts/kaiser-natron-pulver-250-g-großpackung-removebg-preview.png differ diff --git a/public/products/gazelle-waeschestaerke-1000-ml-flasche.jpg b/public/products/gazelle-waeschestaerke-1000-ml-flasche.jpg new file mode 100644 index 0000000..5c713b6 Binary files /dev/null and b/public/products/gazelle-waeschestaerke-1000-ml-flasche.jpg differ diff --git a/public/products/gruene-tante-mit-quarzmehl-500-ml-dose.jpg b/public/products/gruene-tante-mit-quarzmehl-500-ml-dose.jpg new file mode 100644 index 0000000..6267d3b Binary files /dev/null and b/public/products/gruene-tante-mit-quarzmehl-500-ml-dose.jpg differ diff --git a/public/products/holste-handwaschpaste-500-ml.jpg b/public/products/holste-handwaschpaste-500-ml.jpg new file mode 100644 index 0000000..c286fec Binary files /dev/null and b/public/products/holste-handwaschpaste-500-ml.jpg differ diff --git a/public/products/holste-kalk--und-urinsteinloeser-750-ml.jpg b/public/products/holste-kalk--und-urinsteinloeser-750-ml.jpg new file mode 100644 index 0000000..68f9c3c Binary files /dev/null and b/public/products/holste-kalk--und-urinsteinloeser-750-ml.jpg differ diff --git a/public/products/holste-reisstaerke-250-g-faltschachtel.jpg b/public/products/holste-reisstaerke-250-g-faltschachtel.jpg new file mode 100644 index 0000000..8c945f7 Binary files /dev/null and b/public/products/holste-reisstaerke-250-g-faltschachtel.jpg differ diff --git a/public/products/holste-schmierseife-fluessig-1-l-flasche.jpg b/public/products/holste-schmierseife-fluessig-1-l-flasche.jpg new file mode 100644 index 0000000..454a2c7 Binary files /dev/null and b/public/products/holste-schmierseife-fluessig-1-l-flasche.jpg differ diff --git a/public/products/holste-wasch-soda-500-g-beutel.jpg b/public/products/holste-wasch-soda-500-g-beutel.jpg new file mode 100644 index 0000000..f77fe3b Binary files /dev/null and b/public/products/holste-wasch-soda-500-g-beutel.jpg differ diff --git a/public/products/holste-wasch-soda-500-g-beutel.webp b/public/products/holste-wasch-soda-500-g-beutel.webp deleted file mode 100644 index e04962a..0000000 Binary files a/public/products/holste-wasch-soda-500-g-beutel.webp and /dev/null differ diff --git a/public/products/holste-zitronensaeure-entkalker-fluessig-500-ml.jpg b/public/products/holste-zitronensaeure-entkalker-fluessig-500-ml.jpg new file mode 100644 index 0000000..5005626 Binary files /dev/null and b/public/products/holste-zitronensaeure-entkalker-fluessig-500-ml.jpg differ diff --git a/public/products/kaiser-natron-allzweck-reiniger-750-ml.jpg b/public/products/kaiser-natron-allzweck-reiniger-750-ml.jpg new file mode 100644 index 0000000..acef371 Binary files /dev/null and b/public/products/kaiser-natron-allzweck-reiniger-750-ml.jpg differ diff --git a/public/products/kaiser-natron-allzweck-reiniger-750-ml.webp b/public/products/kaiser-natron-allzweck-reiniger-750-ml.webp deleted file mode 100644 index c21cb8a..0000000 Binary files a/public/products/kaiser-natron-allzweck-reiniger-750-ml.webp and /dev/null differ diff --git a/public/products/kaiser-natron-allzweck-spray-500-ml.jpg b/public/products/kaiser-natron-allzweck-spray-500-ml.jpg new file mode 100644 index 0000000..1cdc9d6 Binary files /dev/null and b/public/products/kaiser-natron-allzweck-spray-500-ml.jpg differ diff --git a/public/products/kaiser-natron-allzweck-spray-500-ml.webp b/public/products/kaiser-natron-allzweck-spray-500-ml.webp deleted file mode 100644 index 0f67b29..0000000 Binary files a/public/products/kaiser-natron-allzweck-spray-500-ml.webp and /dev/null differ diff --git a/public/products/kaiser-natron-bad-500-g (1).webp b/public/products/kaiser-natron-bad-500-g (1).webp deleted file mode 100644 index 2eb863e..0000000 Binary files a/public/products/kaiser-natron-bad-500-g (1).webp and /dev/null differ diff --git a/public/products/kaiser-natron-bad-500-g.jpg b/public/products/kaiser-natron-bad-500-g.jpg new file mode 100644 index 0000000..5d5f81e Binary files /dev/null and b/public/products/kaiser-natron-bad-500-g.jpg differ diff --git a/public/products/kaiser-natron-bad-500-g.webp b/public/products/kaiser-natron-bad-500-g.webp deleted file mode 100644 index 2eb863e..0000000 Binary files a/public/products/kaiser-natron-bad-500-g.webp and /dev/null differ diff --git a/public/products/kaiser-natron-daunenwasch-250-ml.jpg b/public/products/kaiser-natron-daunenwasch-250-ml.jpg new file mode 100644 index 0000000..3f1a697 Binary files /dev/null and b/public/products/kaiser-natron-daunenwasch-250-ml.jpg differ diff --git a/public/products/kaiser-natron-fussbad-500-g.jpg b/public/products/kaiser-natron-fussbad-500-g.jpg new file mode 100644 index 0000000..35bdc01 Binary files /dev/null and b/public/products/kaiser-natron-fussbad-500-g.jpg differ diff --git a/public/products/kaiser-natron-fussbad-500-g.webp b/public/products/kaiser-natron-fussbad-500-g.webp deleted file mode 100644 index 6c18175..0000000 Binary files a/public/products/kaiser-natron-fussbad-500-g.webp and /dev/null differ diff --git a/public/products/kaiser-natron-pulver-250-g-großpackung.jpg b/public/products/kaiser-natron-pulver-250-g-großpackung.jpg new file mode 100644 index 0000000..7af0fdf Binary files /dev/null and b/public/products/kaiser-natron-pulver-250-g-großpackung.jpg differ diff --git a/public/products/kaiser-natron-pulver-250-g-großpackung.webp b/public/products/kaiser-natron-pulver-250-g-großpackung.webp deleted file mode 100644 index 335d93e..0000000 Binary files a/public/products/kaiser-natron-pulver-250-g-großpackung.webp and /dev/null differ diff --git a/public/products/kaiser-natron-pulver-3.490-g-eimer.jpg b/public/products/kaiser-natron-pulver-3.490-g-eimer.jpg new file mode 100644 index 0000000..f96c463 Binary files /dev/null and b/public/products/kaiser-natron-pulver-3.490-g-eimer.jpg differ diff --git a/public/products/kaiser-natron-pulver-3.490-g-eimer.webp b/public/products/kaiser-natron-pulver-3.490-g-eimer.webp deleted file mode 100644 index e759e45..0000000 Binary files a/public/products/kaiser-natron-pulver-3.490-g-eimer.webp and /dev/null differ diff --git a/public/products/kaiser-natron-pulver-50-g-beutel.jpg b/public/products/kaiser-natron-pulver-50-g-beutel.jpg new file mode 100644 index 0000000..b4f5bb6 Binary files /dev/null and b/public/products/kaiser-natron-pulver-50-g-beutel.jpg differ diff --git a/public/products/kaiser-natron-pulver-50-g-beutel.webp b/public/products/kaiser-natron-pulver-50-g-beutel.webp deleted file mode 100644 index 8bac1dd..0000000 Binary files a/public/products/kaiser-natron-pulver-50-g-beutel.webp and /dev/null differ diff --git a/public/products/kaiser-natron-sport-profi-250-ml.jpg b/public/products/kaiser-natron-sport-profi-250-ml.jpg new file mode 100644 index 0000000..681004a Binary files /dev/null and b/public/products/kaiser-natron-sport-profi-250-ml.jpg differ diff --git a/public/products/kaiser-natron-spuelmittel-500-ml.jpg b/public/products/kaiser-natron-spuelmittel-500-ml.jpg new file mode 100644 index 0000000..ccf098f Binary files /dev/null and b/public/products/kaiser-natron-spuelmittel-500-ml.jpg differ diff --git a/public/products/kaiser-natron-spuelmittel-500-ml.webp b/public/products/kaiser-natron-spuelmittel-500-ml.webp deleted file mode 100644 index 95f953d..0000000 Binary files a/public/products/kaiser-natron-spuelmittel-500-ml.webp and /dev/null differ diff --git a/public/products/kaiser-natron-tabletten-100-g-dose.jpg b/public/products/kaiser-natron-tabletten-100-g-dose.jpg new file mode 100644 index 0000000..531bc10 Binary files /dev/null and b/public/products/kaiser-natron-tabletten-100-g-dose.jpg differ diff --git a/public/products/kaiser-natron-tabletten-100-g-dose.webp b/public/products/kaiser-natron-tabletten-100-g-dose.webp deleted file mode 100644 index 1c67821..0000000 Binary files a/public/products/kaiser-natron-tabletten-100-g-dose.webp and /dev/null differ diff --git a/public/products/linda-fleckenweg-200-ml-tube.jpg b/public/products/linda-fleckenweg-200-ml-tube.jpg new file mode 100644 index 0000000..1039a04 Binary files /dev/null and b/public/products/linda-fleckenweg-200-ml-tube.jpg differ diff --git a/public/products/linda-handreiniger-der-kraftvolle-200-g-tube.jpg b/public/products/linda-handreiniger-der-kraftvolle-200-g-tube.jpg new file mode 100644 index 0000000..1039a04 Binary files /dev/null and b/public/products/linda-handreiniger-der-kraftvolle-200-g-tube.jpg differ diff --git a/public/products/linda-neutral-375-ml-dose.jpg b/public/products/linda-neutral-375-ml-dose.jpg new file mode 100644 index 0000000..9cc34e7 Binary files /dev/null and b/public/products/linda-neutral-375-ml-dose.jpg differ diff --git a/scripts/remove-bg.py b/scripts/remove-bg.py new file mode 100644 index 0000000..742135e --- /dev/null +++ b/scripts/remove-bg.py @@ -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()) diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..61a2b62 --- /dev/null +++ b/scripts/requirements.txt @@ -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 diff --git a/src/App.vue b/src/App.vue index b44248a..d662738 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,10 +1,27 @@ diff --git a/src/components/SplashIntro.vue b/src/components/SplashIntro.vue new file mode 100644 index 0000000..70703ed --- /dev/null +++ b/src/components/SplashIntro.vue @@ -0,0 +1,220 @@ + + + + + + diff --git a/src/design-system/components/Badge.vue b/src/design-system/components/Badge.vue index e9fac88..dfb4f03 100644 --- a/src/design-system/components/Badge.vue +++ b/src/design-system/components/Badge.vue @@ -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], ]) diff --git a/src/design-system/components/Button.vue b/src/design-system/components/Button.vue index 8ab8726..ca694f2 100644 --- a/src/design-system/components/Button.vue +++ b/src/design-system/components/Button.vue @@ -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(() => [ diff --git a/src/design-system/components/Card.vue b/src/design-system/components/Card.vue index f96e09a..8b498a7 100644 --- a/src/design-system/components/Card.vue +++ b/src/design-system/components/Card.vue @@ -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', }