add styling and media cleanup

This commit is contained in:
Ermin Zoronjic 2026-05-05 13:46:23 +02:00
parent 3dc3086f25
commit 1cf9a7271d
69 changed files with 1246 additions and 386 deletions

View File

@ -1,16 +1,59 @@
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Questrial&display=swap"
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>parfum-shop</title>
<title>atmos · Konzeptionelle Düfte aus der Schweiz</title>
<meta
name="description"
content="atmos — konzeptionelle Nischendüfte zwischen Materialität, Raum und Charakter. Sechs Düfte als Discovery Set oder 50 ml Flakon. Made in Switzerland."
/>
<meta name="theme-color" content="#262626" />
<meta name="color-scheme" content="dark light" />
<!-- TODO: replace https://atmos.example with the real production domain -->
<link rel="canonical" href="https://atmos.example/" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="atmos" />
<meta property="og:title" content="atmos · Konzeptionelle Düfte" />
<meta
property="og:description"
content="Konzeptionelle Nischendüfte zwischen Materialität, Raum und Charakter. Made in Switzerland."
/>
<meta property="og:url" content="https://atmos.example/" />
<meta property="og:image" content="https://atmos.example/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="de_CH" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="atmos · Konzeptionelle Düfte" />
<meta
name="twitter:description"
content="Konzeptionelle Nischendüfte zwischen Materialität, Raum und Charakter."
/>
<meta name="twitter:image" content="https://atmos.example/og-image.jpg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link
rel="preload"
href="/fonts/questrial/questrial-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- Preload LCP image of the landing hero -->
<link
rel="preload"
as="image"
href="/blasse-seide-hero-product.webp"
fetchpriority="high"
/>
</head>
<body>
<div id="root"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,93 @@
Copyright 2011 The Questrial Project Authors (https://github.com/googlefonts/questrial)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,8 @@
# robots.txt — atmos parfum-shop
# TODO: replace https://atmos.example with the real production domain
User-agent: *
Allow: /
Disallow: /api/
Sitemap: https://atmos.example/sitemap.xml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://atmos.ch/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://atmos.ch/about</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://atmos.ch/discovery-set</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://atmos.ch/small-batch</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://atmos.ch/support</loc>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://atmos.ch/impressum</loc>
<changefreq>yearly</changefreq>
<priority>0.2</priority>
</url>
<url>
<loc>https://atmos.ch/datenschutz</loc>
<changefreq>yearly</changefreq>
<priority>0.2</priority>
</url>
<url>
<loc>https://atmos.ch/duft/kalter-beton</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://atmos.ch/duft/nasser-marmor</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://atmos.ch/duft/blasse-seide</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://atmos.ch/duft/weisse-asche</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://atmos.ch/duft/verbranntes-chrom</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://atmos.ch/duft/schwarzes-benzin</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -59,6 +59,10 @@ function App() {
<ProductTransitionProvider>
<ScrollToTop />
<a href="#main-content" className="skip-link">
Zum Inhalt springen
</a>
<div ref={routeContentRef} data-route-content>
<Routes>
<Route path="/" element={<LandingPage />} />

View File

@ -4,9 +4,9 @@
overflow: hidden;
background:
radial-gradient(circle at 82% 10%, rgba(var(--theme-accent-rgb) / 0.15), transparent 22rem),
#171717;
color: #f5f5f5;
border-top: 1px solid rgba(255, 255, 255, 0.08);
var(--footer-bg);
color: var(--footer-text);
border-top: 1px solid var(--footer-border);
}
.site-footer--flush {
@ -18,7 +18,7 @@
position: absolute;
right: var(--page-x);
bottom: -0.16em;
color: rgba(255, 255, 255, 0.035);
color: var(--footer-watermark);
font-size: clamp(5.5rem, 18vw, 20rem);
line-height: 0.8;
letter-spacing: 0;
@ -54,7 +54,7 @@
.site-footer__text {
max-width: 32rem;
margin: 0;
color: rgba(255, 255, 255, 0.7);
color: var(--footer-text-muted);
font-size: var(--text-base);
line-height: 1.65;
}
@ -67,7 +67,7 @@
}
.site-footer__heading {
color: rgba(255, 255, 255, 0.52);
color: var(--footer-text-faint);
font-size: var(--text-xs);
letter-spacing: 0.22em;
text-transform: uppercase;
@ -81,7 +81,7 @@
.site-footer__nav a {
width: fit-content;
color: #f5f5f5;
color: var(--footer-text);
font-size: var(--text-sm);
line-height: 1.2;
text-decoration: none;

View File

@ -94,7 +94,7 @@
height: auto;
object-fit: contain;
display: block;
filter: drop-shadow(0 34px 72px rgba(0, 0, 0, 0.42));
filter: var(--shadow-product);
}
.is-transition-arriving .product-hero-image,
@ -250,46 +250,75 @@
}
.size-card {
min-height: 112px;
padding: clamp(0.85rem, 1.4vw, 1.05rem);
position: relative;
min-height: 116px;
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: clamp(0.95rem, 1.6vw, 1.2rem);
border: 1px solid var(--theme-border);
background: var(--theme-paper);
background: transparent;
color: var(--theme-text);
text-align: left;
cursor: pointer;
isolation: isolate;
transition:
transform var(--duration-med) var(--ease-out),
border-color var(--duration-med) var(--ease-out),
background-color var(--duration-med) var(--ease-out),
box-shadow var(--duration-med) var(--ease-out);
color var(--duration-med) var(--ease-out);
}
/* Top accent line — appears on hover, stays on active. */
.size-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--theme-accent);
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--duration-med) var(--ease-out);
}
.size-card:hover,
.size-card:focus-visible {
transform: translateY(-2px);
border-color: rgba(var(--theme-accent-rgb) / 0.58);
box-shadow: var(--theme-shadow-soft);
border-color: var(--theme-border-strong);
}
.size-card:hover::before,
.size-card:focus-visible::before {
transform: scaleX(0.45);
}
.size-card.active {
border-color: var(--theme-accent);
background:
linear-gradient(135deg, rgba(var(--theme-accent-rgb) / 0.12), transparent 62%),
var(--theme-paper);
background: rgba(var(--theme-accent-rgb) / 0.06);
}
.size-card.active::before {
transform: scaleX(1);
}
.size-title {
display: block;
margin-bottom: 0.65rem;
color: var(--theme-text);
font-size: var(--text-sm);
color: var(--theme-text-muted);
font-size: var(--text-xs);
letter-spacing: 0.18em;
text-transform: uppercase;
transition: color var(--duration-med) var(--ease-out);
}
.size-card.active .size-title {
color: var(--theme-accent);
}
.size-card strong {
display: block;
margin-bottom: 0.35rem;
margin-top: auto;
color: var(--theme-text);
font-size: clamp(1.05rem, 1.4vw, 1.38rem);
font-size: clamp(1.1rem, 1.55vw, 1.45rem);
font-weight: 400;
letter-spacing: 0;
line-height: 1.05;
@ -931,7 +960,7 @@
width: min(86%, 430px);
height: auto;
object-fit: contain;
filter: drop-shadow(0 28px 58px rgba(0, 0, 0, 0.34));
filter: var(--shadow-product-card);
transition: transform var(--duration-slow) var(--ease-out);
}
@ -1170,18 +1199,18 @@
}
.size-card {
min-height: 62px;
padding: 0.55rem 0.6rem;
min-height: 70px;
gap: 0.2rem;
padding: 0.6rem 0.7rem;
}
.size-title {
margin-bottom: 0.25rem;
font-size: 0.76rem;
font-size: 0.66rem;
letter-spacing: 0.14em;
}
.size-card strong {
margin-bottom: 0;
font-size: 0.95rem;
font-size: 0.98rem;
}
.size-card small,

View File

@ -2,12 +2,53 @@ import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import perfumes from "../data/perfumes";
import SharedNavbar from "./SharedNavbar";
import PageMeta from "./seo/PageMeta";
import { useProductTransition } from "../transitions/ProductTransitionContext";
import { formatChf } from "../shop/money";
import { useShop } from "../shop/useShop";
import { formatChf } from "../shop/money";
import "./ProductDetailPage.css";
const STORY_PANEL_IMAGE = "/placeholder-character-panel.jpg";
// TODO: replace with the real production origin once available.
const SITE_ORIGIN = "https://atmos.example";
function buildProductJsonLd(perfume) {
const priceMatch = String(perfume.prices?.full || "").match(/(\d+)/);
const price = priceMatch ? Number(priceMatch[1]) : undefined;
const reviews = perfume.reviews;
const data = {
"@context": "https://schema.org",
"@type": "Product",
name: perfume.name,
sku: `${perfume.id}-${perfume.slug}`,
description: `${perfume.text} ${perfume.mood}`.trim(),
image: `${SITE_ORIGIN}${perfume.image}`,
brand: { "@type": "Brand", name: "atmos" },
category: "Parfum",
};
if (price !== undefined) {
data.offers = {
"@type": "Offer",
priceCurrency: "CHF",
price,
availability: "https://schema.org/InStock",
url: `${SITE_ORIGIN}/duft/${perfume.slug}`,
};
}
if (reviews?.score && reviews?.total) {
data.aggregateRating = {
"@type": "AggregateRating",
ratingValue: reviews.score,
reviewCount: reviews.total,
bestRating: 5,
worstRating: 1,
};
}
return data;
}
const priceToCents = (price) => {
const match = String(price).match(/(\d+)/);
@ -24,6 +65,8 @@ const getSampleImage = (perfume) =>
const getImageForSize = (perfume, size) =>
size === "sample" ? getSampleImage(perfume) : getFullSizeImage(perfume);
const STORY_PANEL_IMAGE = "/placeholder-character-panel.jpg";
function ProductPurchasePanel({
perfume,
selectedSize,
@ -144,10 +187,12 @@ function ProductHero({
<div className="product-hero-visual">
<img
src={selectedImage}
alt={perfume.name}
alt={`${perfume.name} Parfumflakon`}
className="product-hero-image"
data-product-transition-target={perfume.slug}
decoding="async"
loading="eager"
fetchPriority="high"
/>
</div>
@ -218,7 +263,7 @@ function ProductStorySection({ perfume }) {
<img
className="story-visual-image"
src={STORY_PANEL_IMAGE}
alt=""
alt={`Material- und Stimmungsstudie zu ${perfume.name}`}
loading="lazy"
decoding="async"
/>
@ -545,11 +590,23 @@ function ProductDetailContent({ perfumeSlug }) {
return () => window.clearInterval(interval);
}, [safeCommentPages.length]);
const productJsonLd = useMemo(() => buildProductJsonLd(perfume), [perfume]);
return (
<div className={`detail-page ${isTransitionArriving ? "is-transition-arriving" : ""}`}>
<PageMeta
title={`${perfume.name} · Edition ${perfume.id}`}
description={`${perfume.text} ${perfume.mood} ${perfume.concentration}.`}
path={`/duft/${perfume.slug}`}
image={perfume.image}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(productJsonLd) }}
/>
<SharedNavbar variant="hero" brandMode="back" />
<main className="shell">
<main id="main-content" className="shell">
<ProductHero
perfume={perfume}
selectedImage={selectedImage}

View File

@ -23,7 +23,7 @@
object-fit: contain;
transform-origin: 0 0;
will-change: transform, opacity;
filter: drop-shadow(0 34px 82px rgba(0, 0, 0, 0.42));
filter: var(--shadow-product);
}
body.product-transition-active {

View File

@ -6,56 +6,74 @@ import "../style/navbar.css";
function SharedNavbar({ variant = "hero", active = "", brandMode = "logo" }) {
const { cart, openCart, openProfile, user } = useShop();
const { isLight, toggleTheme } = useTheme();
const cartLabel =
cart.total_quantity > 0 ? `Cart ${cart.total_quantity}` : "Cart";
const cartCount = cart.total_quantity || 0;
const cartLabel = cartCount > 0 ? `Cart ${cartCount}` : "Cart";
const cartAriaLabel =
cartCount > 0
? `Warenkorb mit ${cartCount} ${cartCount === 1 ? "Artikel" : "Artikeln"} öffnen`
: "Warenkorb öffnen";
const logoSrc =
variant === "hero" ? "/atmos-logo-light.svg" : "/atmos-logo-dark.svg";
const brandIsBack = brandMode === "back";
return (
<nav className={`navbar navbar--${variant}`} aria-label="Hauptnavigation">
<div className="nav-pill">
<Link
to="/"
className={`nav-link nav-link--brand ${brandIsBack ? "nav-link--back" : ""} ${
active === "atmos" ? "active" : ""
}`}
aria-label={brandIsBack ? "Zur Startseite" : "Atmos Startseite"}
>
{brandIsBack ? (
<>
<span className="nav-back-icon" aria-hidden="true" />
<span>Zurück</span>
</>
) : (
<img src={logoSrc} alt="" className="nav-brand-logo" />
)}
</Link>
<Link
to="/discovery-set"
className={`nav-link ${active === "testen" ? "active" : ""}`}
>
Testen
</Link>
<button type="button" className="nav-link nav-button" onClick={openCart}>
{cartLabel}
</button>
<button type="button" className="nav-link nav-button" onClick={openProfile}>
{user ? "Profile" : "Profile"}
</button>
<button
type="button"
className={`nav-link nav-button nav-theme-switch ${isLight ? "is-light" : ""}`}
onClick={toggleTheme}
aria-label={isLight ? "Switch to dark mode" : "Switch to light mode"}
aria-pressed={isLight}
>
<span className="nav-theme-switch__track" aria-hidden="true">
<span className="nav-theme-switch__thumb" />
</span>
</button>
</div>
</nav>
<header className={`site-header site-header--${variant}`}>
<nav className={`navbar navbar--${variant}`} aria-label="Hauptnavigation">
<div className="nav-pill">
<Link
to="/"
className={`nav-link nav-link--brand ${brandIsBack ? "nav-link--back" : ""} ${
active === "atmos" ? "active" : ""
}`}
aria-label={brandIsBack ? "Zur Startseite" : "Atmos Startseite"}
>
{brandIsBack ? (
<>
<span className="nav-back-icon" aria-hidden="true" />
<span>Zurück</span>
</>
) : (
<img src={logoSrc} alt="" className="nav-brand-logo" />
)}
</Link>
<Link
to="/discovery-set"
className={`nav-link ${active === "testen" ? "active" : ""}`}
>
Testen
</Link>
<button
type="button"
className="nav-link nav-button"
onClick={openCart}
aria-haspopup="dialog"
aria-label={cartAriaLabel}
>
{cartLabel}
</button>
<button
type="button"
className="nav-link nav-button"
onClick={openProfile}
aria-haspopup="dialog"
aria-label={user ? "Profil öffnen" : "Anmelden oder Profil öffnen"}
>
Profile
</button>
<button
type="button"
className={`nav-link nav-button nav-theme-switch ${isLight ? "is-light" : ""}`}
onClick={toggleTheme}
aria-label={isLight ? "Zum Dark Mode wechseln" : "Zum Light Mode wechseln"}
aria-pressed={isLight}
>
<span className="nav-theme-switch__track" aria-hidden="true">
<span className="nav-theme-switch__thumb" />
</span>
</button>
</div>
</nav>
</header>
);
}

View File

@ -319,7 +319,11 @@ function SupportChatbot() {
<div className="chatbot-footer">
<form className="chatbot-form" onSubmit={handleSubmit}>
<label htmlFor="chatbot-input" className="visually-hidden">
Deine Frage an den Support
</label>
<input
id="chatbot-input"
type="text"
className="chatbot-input"
placeholder="Deine Frage eingeben..."

View File

@ -6,6 +6,7 @@ function HeroSection({
heroImageRef,
setHeadlinePrimaryRef,
setHeadlineSecondaryRef,
setHeadlineTertiaryRef,
setWordmarkRef,
setDescriptionRef,
setActionsRef,
@ -56,7 +57,9 @@ function HeroSection({
</span>
</span>
<span className="hero-copy-line">
<span className="reveal-line">Raum und Charakter.</span>
<span className="reveal-line" ref={setHeadlineTertiaryRef}>
Raum und Charakter.
</span>
</span>
</h1>

View File

@ -0,0 +1,31 @@
/**
* Container horizontally constrained content wrapper.
*
* Sizes map to design tokens in src/style/tokens.css:
* default --container (max 1440px)
* narrow --container-narrow (max 920px, for prose)
* wide --container-wide (max 1680px, for hero/grid pages)
*/
function Container({
as = "div",
size = "default",
className = "",
children,
...rest
}) {
const Tag = as;
const sizeClass =
size === "narrow"
? "container-narrow"
: size === "wide"
? "container-wide"
: "container";
return (
<Tag className={`${sizeClass}${className ? ` ${className}` : ""}`} {...rest}>
{children}
</Tag>
);
}
export default Container;

View File

@ -0,0 +1,67 @@
import { forwardRef } from "react";
/**
* 12-column grid wrapper. Mobile-first: children span the full width by
* default and narrow at md/lg breakpoints via the Col `md`/`lg` props.
*
* <Grid gap="md">
* <Col span={12} md={6} lg={4}></Col>
* </Grid>
*/
const Grid = forwardRef(function Grid(
{ gap = "md", className = "", children, as = "div", ...rest },
ref
) {
const Tag = as;
const gapClass = `grid-gap-${gap}`;
return (
<Tag
ref={ref}
className={`grid-12 ${gapClass}${className ? ` ${className}` : ""}`}
{...rest}
>
{children}
</Tag>
);
});
/**
* Grid column. Pass `span` for the mobile-first base, then `md`/`lg`
* for tablet/desktop overrides. Optional `start` / `mdStart` / `lgStart`
* for absolute placement.
*/
export const Col = forwardRef(function Col(
{
span = 12,
md,
lg,
start,
mdStart,
lgStart,
className = "",
children,
as = "div",
...rest
},
ref
) {
const Tag = as;
const classes = [`col-span-${span}`];
if (md) classes.push(`md:col-span-${md}`);
if (lg) classes.push(`lg:col-span-${lg}`);
if (start) classes.push(`col-start-${start}`);
if (mdStart) classes.push(`md:col-start-${mdStart}`);
if (lgStart) classes.push(`lg:col-start-${lgStart}`);
return (
<Tag
ref={ref}
className={`${classes.join(" ")}${className ? ` ${className}` : ""}`}
{...rest}
>
{children}
</Tag>
);
});
export default Grid;

View File

@ -0,0 +1,38 @@
import Container from "./Container";
/**
* Section semantic <section> with vertical rhythm and an inner Container.
*
* spacing: xs | sm | md | lg (maps to --section-y-* tokens)
* container: default | narrow | wide | none
*/
function Section({
as = "section",
spacing = "sm",
container = "default",
id,
className = "",
children,
...rest
}) {
const Tag = as;
const spacingClass = `section-pad-${spacing}`;
const inner =
container === "none" ? (
children
) : (
<Container size={container}>{children}</Container>
);
return (
<Tag
id={id}
className={`${spacingClass}${className ? ` ${className}` : ""}`}
{...rest}
>
{inner}
</Tag>
);
}
export default Section;

View File

@ -0,0 +1,58 @@
/**
* PageMeta per-page SEO metadata.
*
* Relies on React 19's native document metadata hoisting: any <title>,
* <meta>, or <link> rendered inside a component is moved to <head> at
* mount time, so no third-party helmet library is required.
*
* <PageMeta
* title="…"
* description="…"
* path="/discovery-set"
* image="/og-image-discovery.jpg"
* />
*
* Falls back to the brand suffix " · atmos" unless `bareTitle` is true.
*/
const SITE_NAME = "atmos";
// TODO: replace with the real production origin once available.
const SITE_ORIGIN = "https://atmos.example";
const DEFAULT_OG_IMAGE = `${SITE_ORIGIN}/og-image.jpg`;
function PageMeta({
title,
description,
path = "/",
image = DEFAULT_OG_IMAGE,
bareTitle = false,
}) {
const fullTitle = bareTitle ? title : `${title} · ${SITE_NAME}`;
const canonical = path.startsWith("http")
? path
: `${SITE_ORIGIN}${path.startsWith("/") ? path : `/${path}`}`;
const ogImage = image?.startsWith("http") ? image : `${SITE_ORIGIN}${image}`;
return (
<>
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta property="og:locale" content="de_CH" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />
</>
);
}
export default PageMeta;

View File

@ -3,7 +3,7 @@ const perfumes = [
id: "01",
slug: "kalter-beton",
name: "KALTER BETON",
image: "/kalter-beton-product-image.png",
image: "/kalter-beton-product-image.webp",
fillImage: "/platzhalter.png",
fillVideo: "/kalter-beton-hover.webm",
text: "Mineralisch. Roh. Unberührt.",
@ -26,8 +26,8 @@ const perfumes = [
concentration: "Eau de Parfum (18%)",
edition: "Batch 04/24",
gallery: [
"/kalter-beton-product-image.png",
"/kalter-beton-product-sample-image.png",
"/kalter-beton-product-image.webp",
"/kalter-beton-product-sample-webp",
"/kalter-beton-product-image.png",
],
reviews: {
@ -83,7 +83,7 @@ const perfumes = [
id: "02",
slug: "nasser-marmor",
name: "NASSER MARMOR",
image: "/nasser-marmor-product-image.png",
image: "/nasser-marmor-product-image.webp",
fillImage: "/platzhalter.png",
fillVideo: "/nasser-marmor-hover.webm",
text: "Kühl. Glatt. Sinnlich.",
@ -106,8 +106,8 @@ const perfumes = [
concentration: "Eau de Parfum (17%)",
edition: "Batch 02/24",
gallery: [
"/nasser-marmor-product-image.png",
"/nasser-marmor-product-sample-image.png",
"/nasser-marmor-product-image.webp",
"/nasser-marmor-product-sample-image.webp",
"/nasser-marmor-product-image.png",
],
reviews: {
@ -163,7 +163,7 @@ const perfumes = [
id: "03",
slug: "blasse-seide",
name: "BLASSE SEIDE",
image: "/blasse-seide-product-image.png",
image: "/blasse-seide-product-image.webp",
fillImage: "/platzhalter.png",
fillVideo: "/blasse-seide-hover.webm",
text: "Blass. Sanft. Kostbar.",
@ -186,8 +186,8 @@ const perfumes = [
concentration: "Eau de Parfum (16%)",
edition: "Batch 03/24",
gallery: [
"/blasse-seide-product-image.png",
"/blasse-seide-product-sample-image.png",
"/blasse-seide-product-image.webp",
"/blasse-seide-product-sample-image.webp",
"/blasse-seide-product-image.png",
],
reviews: {
@ -243,7 +243,7 @@ const perfumes = [
id: "04",
slug: "weisse-asche",
name: "WEISSE ASCHE",
image: "/weisse-asche-product-image.png",
image: "/weisse-asche-product-image.webp",
fillImage: "/platzhalter.png",
fillVideo: "/weisse-asche-hover.webm",
text: "Still. Staubig. Erhaben.",
@ -266,8 +266,8 @@ const perfumes = [
concentration: "Eau de Parfum (19%)",
edition: "Batch 01/24",
gallery: [
"/weisse-asche-product-image.png",
"/weisse-asche-product-sample-image.png",
"/weisse-asche-product-image.webp",
"/weisse-asche-product-sample-image.webp",
"/weisse-asche-product-image.png",
],
reviews: {
@ -323,7 +323,7 @@ const perfumes = [
id: "05",
slug: "verbranntes-chrom",
name: "VERBRANNTES CHROM",
image: "/verbranntes-chrom-product-image.png",
image: "/verbranntes-chrom-product-image.webp",
fillImage: "/platzhalter.png",
fillVideo: "/verbranntes-chrom-hover.webm",
text: "Metallisch. Verzehrt. Edel.",
@ -346,8 +346,8 @@ const perfumes = [
concentration: "Extrait de Parfum (24%)",
edition: "Batch 05/24",
gallery: [
"/verbranntes-chrom-product-image.png",
"/verbranntes-chrom-product-sample-image.png",
"/verbranntes-chrom-product-image.webp",
"/verbrannteschrom-product-sample-image.webp",
"/verbranntes-chrom-product-image.png",
],
reviews: {
@ -403,7 +403,7 @@ const perfumes = [
id: "06",
slug: "schwarzes-benzin",
name: "SCHWARZES BENZIN",
image: "/schwarzes-benzin-product-image.png",
image: "/schwarzes-benzin-product-image.webp",
fillImage: "/platzhalter.png",
fillVideo: "/schwarzes-benzin-hover.webm",
text: "Dunkel. Glänzend. Verboten.",
@ -426,8 +426,8 @@ const perfumes = [
concentration: "Eau de Parfum (20%)",
edition: "Batch 06/24",
gallery: [
"/schwarzes-benzin-product-image.png",
"/schwarzes-benzin-product-sample-image.png",
"/schwarzes-benzin-product-image.webp",
"/schwarzes-benzin-product-sample-image.webp",
"/schwarzes-benzin-product-image.png",
],
reviews: {

View File

@ -1,57 +1,51 @@
/**
* Global stylesheet
* ----------------------------------------------------------------------------
* Imports the design-token layer first, then the grid + breakpoint utilities,
* and finally the global resets and base element styles. Page- and
* component-level styles live next to their components.
*/
@import "./style/tokens.css";
@import "./style/grid.css";
@import "./style/breakpoints.css";
@font-face {
font-family: "Questrial";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/questrial/questrial-vietnamese.woff2") format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169,
U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309,
U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: "Questrial";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/questrial/questrial-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: "Questrial";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/questrial/questrial-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
font-family: "Questrial", Arial, Helvetica, sans-serif;
--theme-black: #262626;
--theme-white: #eaeaea;
--theme-accent: #ff6a00;
--theme-accent-rgb: 255 106 0;
--theme-bg: #262626;
--theme-surface: #2f2f2f;
--theme-surface-soft: #363636;
--theme-paper: #404040;
--theme-text: #eaeaea;
--theme-text-muted: #c8c8c8;
--theme-border: #4a4a4a;
--theme-border-strong: rgba(234, 234, 234, 0.26);
--theme-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
--theme-shadow-soft: 0 16px 42px rgba(0, 0, 0, 0.18);
--page-x: clamp(1rem, 4vw, 5rem);
--section-y-xs: clamp(2rem, 5vw, 4.5rem);
--section-y-sm: clamp(3rem, 7vw, 7rem);
--section-y: clamp(4rem, 10vw, 10rem);
--section-y-lg: clamp(5rem, 14vw, 14rem);
--container: min(calc(100% - (var(--page-x) * 2)), 1440px);
--container-narrow: min(calc(100% - (var(--page-x) * 2)), 920px);
--container-wide: min(calc(100% - (var(--page-x) * 2)), 1680px);
--text-measure: 68ch;
--gap-2xs: clamp(0.35rem, 0.7vw, 0.65rem);
--gap-xs: clamp(0.5rem, 1vw, 0.875rem);
--gap-sm: clamp(0.75rem, 1.5vw, 1.25rem);
--gap-md: clamp(1rem, 2vw, 2rem);
--gap-lg: clamp(1.5rem, 4vw, 4rem);
--gap-xl: clamp(2rem, 6vw, 6rem);
--radius-xs: 0;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--text-xs: clamp(0.75rem, 0.72rem + 0.15vw, 0.875rem);
--text-sm: clamp(0.875rem, 0.83rem + 0.2vw, 1rem);
--text-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
--text-lg: clamp(1.125rem, 1.05rem + 0.35vw, 1.375rem);
--text-xl: clamp(1.35rem, 1.15rem + 0.9vw, 2rem);
--text-2xl: clamp(1.75rem, 1.25rem + 2vw, 3.5rem);
--text-display: clamp(3.05rem, 10.5vw, 10.8rem);
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-snap: cubic-bezier(0.16, 1, 0.3, 1);
--duration-fast: 160ms;
--duration-med: 260ms;
--duration-slow: 720ms;
color: var(--theme-text);
background: var(--theme-bg);
@ -70,6 +64,8 @@
html {
scroll-behavior: auto;
overflow-x: hidden;
overflow-x: clip;
}
body {
@ -77,20 +73,11 @@ body {
background: var(--theme-bg);
color: var(--theme-text);
font-family: inherit;
transition: background-color 0.25s ease, color 0.25s ease;
}
body.theme-light {
--theme-bg: #eaeaea;
--theme-surface: #f5f5f5;
--theme-surface-soft: #f0f0f0;
--theme-paper: #ffffff;
--theme-text: #262626;
--theme-text-muted: #5f5f5f;
--theme-border: #d6d6d6;
--theme-border-strong: rgba(38, 38, 38, 0.22);
--theme-shadow: 0 24px 70px rgba(38, 38, 38, 0.13);
--theme-shadow-soft: 0 16px 42px rgba(38, 38, 38, 0.1);
overflow-x: hidden;
overflow-x: clip;
transition:
background-color 0.25s ease,
color 0.25s ease;
}
a {
@ -101,16 +88,6 @@ button {
font: inherit;
}
html {
overflow-x: hidden;
overflow-x: clip;
}
body {
overflow-x: hidden;
overflow-x: clip;
}
img,
picture,
video,
@ -148,6 +125,41 @@ a {
color: var(--theme-white);
}
/* Visually hidden but available to assistive tech */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip link — hidden until keyboard focus moves onto it */
.skip-link {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 9999;
padding: 0.6rem 1rem;
background: var(--theme-accent);
color: #fff;
font-size: var(--text-sm);
text-decoration: none;
transform: translateY(-150%);
transition: transform var(--duration-med) var(--ease-out);
}
.skip-link:focus,
.skip-link:focus-visible {
transform: translateY(0);
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
@ -162,4 +174,3 @@ a {
transition-duration: 0.01ms;
}
}

View File

@ -138,26 +138,14 @@
margin-top: var(--gap-sm);
}
/* These three sections use the global Grid12 system (see Grid.jsx).
We keep only the section-level spacing here. */
.about-proof-strip,
.about-grid-section,
.about-credentials-grid {
display: grid;
gap: var(--gap-sm);
margin-top: var(--section-y-sm);
}
.about-proof-strip {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.about-grid-section {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.about-credentials-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.about-card,
.about-proof-item,
.about-credential-card,
@ -340,12 +328,6 @@
.about-bottom-cta {
grid-template-columns: 1fr;
}
.about-proof-strip,
.about-grid-section,
.about-credentials-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
@ -357,12 +339,6 @@
font-size: clamp(2.55rem, 13vw, 4.4rem);
}
.about-proof-strip,
.about-grid-section,
.about-credentials-grid {
grid-template-columns: 1fr;
}
.about-bottom-actions {
display: grid;
justify-content: stretch;

View File

@ -1,13 +1,68 @@
import { Link } from "react-router";
import SharedNavbar from "../components/SharedNavbar";
import Grid, { Col } from "../components/layout/Grid";
import PageMeta from "../components/seo/PageMeta";
import "./AboutPage.css";
const PROOF_ITEMS = [
{ label: "ATELIER", text: "Entwicklung aus einem kuratierten Duft- und Materialkontext" },
{ label: "KLEINSERIEN", text: "Chargenbasiert gedacht statt massenmarktfähig optimiert" },
{ label: "KOMPOSITION", text: "Materiallogik vor Trendformel und Lautstärke" },
{ label: "HERKUNFT", text: "Creative Direction und Qualitätsanspruch aus der Schweiz" },
];
const APPROACH_CARDS = [
{
label: "01 / DEKODIEREN",
title: "Atmosphäre lesen",
text: "Wir beobachten Licht, Materialität, Temperatur, Distanz und Spannung. Nicht als Moodboard allein, sondern als System von Eindrücken, das eine bestimmte Wirkung erzeugt.",
},
{
label: "02 / VERDICHTEN",
title: "In Duft übersetzen",
text: "Diese Eindrücke werden in eine olfaktorische Sprache übertragen: mineralisch, staubig, metallisch, glatt, rau, still oder warm. So entsteht keine Kopie eines Objekts, sondern seine Atmosphäre.",
},
{
label: "03 / REDUZIEREN",
title: "Wirkung schärfen",
text: "Wir lassen weg, was beliebig ist. Übrig bleibt eine klare Signatur: charaktervoll, hochwertig und bewusst nischig. Ein Duft, der Haltung zeigt, statt nur zu gefallen.",
},
];
const CREDENTIAL_CARDS = [
{
label: "ATELIER / ENTWICKLUNG",
title: "Komposition aus einem klaren Duftverständnis",
text: "Jede Arbeit basiert auf einem kuratierten Konzept, einer definierten Materialwelt und mehreren internen Entwicklungsstufen statt auf kurzfristigen Trendbriefings.",
},
{
label: "MATERIALLOGIK",
title: "Noten folgen einer Idee, nicht bloss einer Wirkung",
text: "Rohstoffe und Akkorde werden danach gewählt, welche Oberfläche, Temperatur oder Spannung sie transportieren nicht nur danach, ob sie möglichst sofort gefallen.",
},
{
label: "CHARGENPRINZIP",
title: "Kleinserie statt gesichtsloser Massenästhetik",
text: "atmos denkt in kontrollierten Batches und klaren Editionen. Das stärkt Nachvollziehbarkeit, Qualitätsfokus und die Eigenständigkeit jeder Komposition.",
},
{
label: "QUALITÄTSPRÜFUNG",
title: "Komposition, Haltbarkeit und Verlauf werden bewusst geprüft",
text: "Bewertet werden nicht nur Auftakt und Präsenz, sondern auch Übergänge, Balance, Textur, Wiedererkennbarkeit und das Verhalten auf Haut über mehrere Stunden.",
},
];
function AboutPage() {
return (
<div className="about-page">
<PageMeta
title="About"
description="atmos dekodiert Atmosphären und übersetzt Materialität, Raum und Charakter in konzeptionelle Düfte. Nischig. Hochwertig. Made in Switzerland."
path="/about"
/>
<SharedNavbar variant="hero" />
<main className="shell">
<main id="main-content" className="shell">
<section className="about-hero" data-reveal-group>
<div className="about-hero-copy">
<span className="about-kicker" data-reveal="fade">
@ -80,24 +135,27 @@ function AboutPage() {
</div>
</section>
<section className="about-proof-strip" data-reveal-group data-reveal-start="top 90%">
<div className="about-proof-item" data-reveal="fade">
<span className="about-label">ATELIER</span>
<p>Entwicklung aus einem kuratierten Duft- und Materialkontext</p>
</div>
<div className="about-proof-item" data-reveal="fade">
<span className="about-label">KLEINSERIEN</span>
<p>Chargenbasiert gedacht statt massenmarktfähig optimiert</p>
</div>
<div className="about-proof-item" data-reveal="fade">
<span className="about-label">KOMPOSITION</span>
<p>Materiallogik vor Trendformel und Lautstärke</p>
</div>
<div className="about-proof-item" data-reveal="fade">
<span className="about-label">HERKUNFT</span>
<p>Creative Direction und Qualitätsanspruch aus der Schweiz</p>
</div>
</section>
<Grid
as="section"
gap="sm"
className="about-proof-strip"
data-reveal-group
data-reveal-start="top 90%"
>
{PROOF_ITEMS.map((item) => (
<Col
key={item.label}
span={12}
md={6}
lg={3}
className="about-proof-item"
data-reveal="fade"
>
<span className="about-label">{item.label}</span>
<p>{item.text}</p>
</Col>
))}
</Grid>
<section className="about-quote-block" data-reveal-group>
<p data-reveal="fade">
@ -106,37 +164,28 @@ function AboutPage() {
</p>
</section>
<section className="about-grid-section" data-reveal-group data-reveal-start="top 84%">
<div className="about-card" data-reveal="fade">
<span className="about-label">01 / DEKODIEREN</span>
<h3>Atmosphäre lesen</h3>
<p>
Wir beobachten Licht, Materialität, Temperatur, Distanz und Spannung.
Nicht als Moodboard allein, sondern als System von Eindrücken, das
eine bestimmte Wirkung erzeugt.
</p>
</div>
<div className="about-card" data-reveal="fade">
<span className="about-label">02 / VERDICHTEN</span>
<h3>In Duft übersetzen</h3>
<p>
Diese Eindrücke werden in eine olfaktorische Sprache übertragen:
mineralisch, staubig, metallisch, glatt, rau, still oder warm. So
entsteht keine Kopie eines Objekts, sondern seine Atmosphäre.
</p>
</div>
<div className="about-card" data-reveal="fade">
<span className="about-label">03 / REDUZIEREN</span>
<h3>Wirkung schärfen</h3>
<p>
Wir lassen weg, was beliebig ist. Übrig bleibt eine klare Signatur:
charaktervoll, hochwertig und bewusst nischig. Ein Duft, der Haltung
zeigt, statt nur zu gefallen.
</p>
</div>
</section>
<Grid
as="section"
gap="sm"
className="about-grid-section"
data-reveal-group
data-reveal-start="top 84%"
>
{APPROACH_CARDS.map((card) => (
<Col
key={card.label}
span={12}
md={6}
lg={4}
className="about-card"
data-reveal="fade"
>
<span className="about-label">{card.label}</span>
<h3>{card.title}</h3>
<p>{card.text}</p>
</Col>
))}
</Grid>
<section
className="about-section about-section--split about-process-section"
@ -168,51 +217,28 @@ function AboutPage() {
</div>
</section>
<section
<Grid
as="section"
gap="sm"
className="about-credentials-grid"
data-reveal-group
data-reveal-start="top 84%"
>
<article className="about-credential-card" data-reveal="fade">
<span className="about-label">ATELIER / ENTWICKLUNG</span>
<h3>Komposition aus einem klaren Duftverständnis</h3>
<p>
Jede Arbeit basiert auf einem kuratierten Konzept, einer definierten
Materialwelt und mehreren internen Entwicklungsstufen statt auf
kurzfristigen Trendbriefings.
</p>
</article>
<article className="about-credential-card" data-reveal="fade">
<span className="about-label">MATERIALLOGIK</span>
<h3>Noten folgen einer Idee, nicht bloss einer Wirkung</h3>
<p>
Rohstoffe und Akkorde werden danach gewählt, welche Oberfläche,
Temperatur oder Spannung sie transportieren nicht nur danach, ob sie
möglichst sofort gefallen.
</p>
</article>
<article className="about-credential-card" data-reveal="fade">
<span className="about-label">CHARGENPRINZIP</span>
<h3>Kleinserie statt gesichtsloser Massenästhetik</h3>
<p>
atmos denkt in kontrollierten Batches und klaren Editionen. Das stärkt
Nachvollziehbarkeit, Qualitätsfokus und die Eigenständigkeit jeder
Komposition.
</p>
</article>
<article className="about-credential-card" data-reveal="fade">
<span className="about-label">QUALITÄTSPRÜFUNG</span>
<h3>Komposition, Haltbarkeit und Verlauf werden bewusst geprüft</h3>
<p>
Bewertet werden nicht nur Auftakt und Präsenz, sondern auch Übergänge,
Balance, Textur, Wiedererkennbarkeit und das Verhalten auf Haut über
mehrere Stunden.
</p>
</article>
</section>
{CREDENTIAL_CARDS.map((card) => (
<Col
key={card.label}
as="article"
span={12}
md={6}
className="about-credential-card"
data-reveal="fade"
>
<span className="about-label">{card.label}</span>
<h3>{card.title}</h3>
<p>{card.text}</p>
</Col>
))}
</Grid>
<section className="about-method-section" data-reveal-group>
<div className="about-method-copy">

View File

@ -1,12 +1,18 @@
import SharedNavbar from "../components/SharedNavbar";
import PageMeta from "../components/seo/PageMeta";
import "./DatenschutzPage.css";
function DatenschutzPage() {
return (
<div className="datenschutz-page">
<PageMeta
title="Datenschutz"
description="Datenschutzerklärung von atmos: Erhebung, Speicherung und Verarbeitung personenbezogener Daten."
path="/datenschutz"
/>
<SharedNavbar variant="hero" />
<main className="shell">
<main id="main-content" className="shell">
<section className="datenschutz-hero" data-reveal-group>
<span className="datenschutz-kicker" data-reveal="fade">RECHTLICHE ANGABEN</span>
<h1 data-reveal="lines">DATENSCHUTZ</h1>

View File

@ -95,7 +95,7 @@
border: 0;
object-fit: contain;
object-position: center;
filter: saturate(0.92) contrast(1.04) drop-shadow(0 34px 72px rgba(0, 0, 0, 0.42));
filter: saturate(0.92) contrast(1.04) var(--shadow-product);
}
.discovery-panel-facts div,
@ -383,15 +383,8 @@
max-width: 11ch;
}
.discovery-steps-grid,
.discovery-products-grid,
.discovery-comparison-grid {
display: grid;
gap: var(--gap-sm);
}
/* Layout columns are provided by the global Grid12 system (see Grid.jsx). */
.discovery-steps-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: clamp(1.6rem, 4vw, 4rem);
}
@ -438,9 +431,7 @@
line-height: 1.65;
}
.discovery-products-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
/* .discovery-products-grid columns provided by Grid12 (see Grid.jsx). */
.discovery-product-card {
min-height: clamp(390px, 40vw, 560px);
@ -465,7 +456,6 @@
display: grid;
place-items: center;
min-height: 0;
overflow: hidden;
background: transparent;
}
@ -474,7 +464,7 @@
width: min(86%, 360px);
height: auto;
object-fit: contain;
filter: drop-shadow(0 24px 48px rgba(0, 0, 0, 0.24));
filter: var(--shadow-product-card);
transition: transform var(--duration-slow) var(--ease-out);
}
@ -509,7 +499,6 @@
.discovery-comparison-grid {
grid-column: 1 / -1;
grid-template-columns: 1fr 1fr;
align-self: start;
}
@ -636,10 +625,7 @@
min-height: auto;
}
.discovery-products-grid,
.discovery-steps-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* products & steps grids: handled by Grid12 md:6 col-spans */
}
@media (max-width: 820px) {
@ -687,12 +673,10 @@
}
.discovery-panel-facts,
.discovery-section-heading,
.discovery-comparison-grid,
.discovery-products-grid,
.discovery-steps-grid {
.discovery-section-heading {
grid-template-columns: 1fr;
}
/* comparison-, products- & steps-grid: Grid12 collapses to 1 col by default */
.discovery-price-row {
order: 1;

View File

@ -1,5 +1,7 @@
import perfumes from "../data/perfumes";
import SharedNavbar from "../components/SharedNavbar";
import Grid, { Col } from "../components/layout/Grid";
import PageMeta from "../components/seo/PageMeta";
import { useShop } from "../shop/useShop";
import "./DiscoverySetPage.css";
@ -162,15 +164,23 @@ function DiscoveryProcessSection() {
<h2 data-reveal="lines">So funktioniert&apos;s</h2>
</div>
<div className="discovery-steps-grid">
<Grid gap="sm" className="discovery-steps-grid">
{discoverySteps.map((step) => (
<article className="discovery-step-card" key={step.number} data-reveal="fade">
<Col
key={step.number}
as="article"
span={12}
md={6}
lg={4}
className="discovery-step-card"
data-reveal="fade"
>
<span className="discovery-step-number">{step.number}</span>
<h3>{step.title}</h3>
<p>{step.text}</p>
</article>
</Col>
))}
</div>
</Grid>
</section>
);
}
@ -185,15 +195,23 @@ function DiscoveryIncludedSection() {
<h2 data-reveal="lines">Alle 6 Signature-Düfte zum Testen.</h2>
</div>
<div className="discovery-products-grid">
<Grid gap="sm" className="discovery-products-grid">
{perfumes.map((perfume) => (
<article className="discovery-product-card" key={perfume.id} data-reveal="fade">
<Col
key={perfume.id}
as="article"
span={12}
md={6}
lg={4}
className="discovery-product-card"
data-reveal="fade"
>
<span className="discovery-product-index">{perfume.id}</span>
<div className="discovery-product-image">
<img
src={perfume.image}
alt={perfume.name}
alt={`Atmos ${perfume.name} Sample im Discovery Set`}
loading="lazy"
decoding="async"
/>
@ -209,9 +227,9 @@ function DiscoveryIncludedSection() {
))}
</div>
</div>
</article>
</Col>
))}
</div>
</Grid>
</section>
);
}
@ -219,13 +237,16 @@ function DiscoveryIncludedSection() {
function DiscoveryComparisonSection() {
return (
<section className="discovery-comparison-section" data-reveal-group>
<div className="discovery-comparison-grid">
<Grid gap="sm" className="discovery-comparison-grid">
{discoveryComparison.map((item) => (
<article
<Col
key={item.title}
as="article"
span={12}
md={6}
className={`discovery-comparison-card${
item.highlight ? " discovery-comparison-card--highlight" : ""
}`}
key={item.title}
data-reveal="fade"
>
<div className="discovery-comparison-head">
@ -235,9 +256,9 @@ function DiscoveryComparisonSection() {
<h3>{item.title}</h3>
</div>
<p>{item.text}</p>
</article>
</Col>
))}
</div>
</Grid>
</section>
);
}
@ -271,9 +292,14 @@ function DiscoverySetPage() {
return (
<div className="discovery-page">
<PageMeta
title="Discovery Set"
description="6 × 2ml Samples aller atmos-Düfte. Eine Woche pro Duft tragen, verstehen, welcher passt. CHF 48, anrechenbar beim Full-Size-Kauf."
path="/discovery-set"
/>
<SharedNavbar variant="hero" active="testen" />
<main className="shell">
<main id="main-content" className="shell">
<DiscoveryHero onBuy={buyDiscoverySet} />
<DiscoveryStorySection />
<DiscoveryIncludedSection />

View File

@ -1,12 +1,18 @@
import SharedNavbar from "../components/SharedNavbar";
import PageMeta from "../components/seo/PageMeta";
import "./ImpressumPage.css";
function ImpressumPage() {
return (
<div className="impressum-page">
<PageMeta
title="Impressum"
description="Rechtliche Angaben, Verantwortlichkeit und Erreichbarkeit von atmos."
path="/impressum"
/>
<SharedNavbar variant="hero" />
<main className="shell">
<main id="main-content" className="shell">
<section className="impressum-hero" data-reveal-group>
<span className="impressum-kicker" data-reveal="fade">
RECHTLICHE ANGABEN

View File

@ -89,10 +89,10 @@ body.theme-light .hero-wordmark__image {
.hero-product {
position: absolute;
top: clamp(22rem, 58vh, 44rem);
top: clamp(20rem, 52vh, 40rem);
left: 50%;
z-index: 5;
width: clamp(19rem, 29vw, 38rem);
width: clamp(20rem, 31vw, 42rem);
aspect-ratio: 1078 / 1284;
margin: 0;
display: grid;
@ -117,6 +117,7 @@ body.theme-light .hero-wordmark__image {
height: 100%;
object-fit: contain;
display: block;
filter: var(--shadow-product);
}
.hero-content {
@ -193,7 +194,6 @@ body.theme-light .hero-wordmark__image {
.btn-primary {
background: var(--theme-accent);
color: #fff;
box-shadow: 0 18px 42px rgba(var(--theme-accent-rgb) / 0.26);
}
.btn-secondary {
@ -309,12 +309,10 @@ body.theme-light .hero-wordmark__image {
grid-column: 1 / span 8;
}
/* GRID */
/* GRID columns provided by the global Grid12 system (see Grid.jsx).
.product-grid only contributes container queries here. */
.product-grid {
container-type: inline-size;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--gap-sm);
}
.product-card {
@ -451,17 +449,6 @@ body.theme-light .hero-wordmark__image {
overflow: hidden;
}
.product-image-wrap::before {
content: "";
position: absolute;
width: min(76%, 26rem);
aspect-ratio: 1;
border-radius: 50%;
background: rgba(var(--theme-accent-rgb) / 0.08);
filter: blur(28px);
transform: translateY(10%);
}
.product-image {
position: relative;
z-index: 1;
@ -469,6 +456,7 @@ body.theme-light .hero-wordmark__image {
height: auto;
object-fit: contain;
border-radius: 0;
filter: var(--shadow-product-card);
transition: transform var(--duration-slow) var(--ease-out);
}
@ -589,9 +577,9 @@ body.theme-light .hero-wordmark__image {
.discovery-btn {
margin-top: clamp(1.3rem, 3vw, 2.1rem);
background: #fff;
color: #d64f00;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.18);
background: var(--theme-surface);
color: var(--theme-text);
}
.discovery-banner {
@ -632,6 +620,9 @@ body.theme-light .hero-wordmark__image {
}
.hero-product {
/* Tablet range ( 1180 px): viewports are taller relative to width,
so we re-center the product instead of inheriting the desktop 52vh. */
top: clamp(17rem, 44vh, 28rem);
width: clamp(21rem, 45vw, 34rem);
}
@ -657,9 +648,7 @@ body.theme-light .hero-wordmark__image {
max-width: 18rem;
}
.product-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* .product-grid columns handled by Grid12 (md:6 / lg:4) */
.discovery-section {
grid-template-columns: 1fr;
@ -688,10 +677,17 @@ body.theme-light .hero-wordmark__image {
}
.hero-product {
top: clamp(20.75rem, 49svh, 25.5rem);
left: 44%;
width: min(72vw, 19rem);
transform: translate(-50%, -58%);
/* Phone: slot spans the page width minus the standard
--page-x gutter on each side. The img inside uses
object-fit: contain, so the bottle stays naturally
centered and proportional within the box. */
aspect-ratio: auto;
top: clamp(11rem, 24svh, 14rem);
left: var(--page-x);
right: var(--page-x);
width: auto;
height: clamp(18rem, 44svh, 24rem);
transform: none;
}
.hero-content {
@ -723,9 +719,7 @@ body.theme-light .hero-wordmark__image {
display: none;
}
.product-grid {
grid-template-columns: 1fr;
}
/* .product-grid mobile: Grid12 base = col-span-12 = single column */
.section-heading::after {
right: auto;
@ -761,9 +755,10 @@ body.theme-light .hero-wordmark__image {
}
.hero-product {
top: clamp(20.25rem, 48svh, 24.5rem);
left: 43%;
width: min(74vw, 18.25rem);
/* Slightly tighter slot for compact phones; centering and
full-width box come from the .hero-product base above. */
top: clamp(10rem, 22svh, 13rem);
height: clamp(17rem, 42svh, 22rem);
}
.hero-copy {

View File

@ -8,10 +8,12 @@ import {
import { Link } from "react-router";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Grid from "../components/layout/Grid";
import HeroSection from "../components/landing/HeroSection";
import SharedNavbar from "../components/SharedNavbar";
import { useProductTransition } from "../transitions/ProductTransitionContext";
import PageMeta from "../components/seo/PageMeta";
import perfumes from "../data/perfumes";
import { useProductTransition } from "../transitions/ProductTransitionContext";
import "../pages/LandingPage.css";
import "../style/navbar.css";
@ -57,6 +59,10 @@ function LandingPage() {
headlineLineRefs.current[1] = element;
}, []);
const setHeadlineTertiaryRef = useCallback((element) => {
headlineLineRefs.current[2] = element;
}, []);
const setWordmarkRef = useCallback((element) => {
heroWordmarkRef.current = element;
}, []);
@ -427,6 +433,12 @@ function LandingPage() {
return (
<div className="page" ref={pageRef}>
<PageMeta
title="atmos · Konzeptionelle Düfte aus der Schweiz"
description="atmos — konzeptionelle Nischendüfte zwischen Materialität, Raum und Charakter. Sechs Düfte als Discovery Set oder 50 ml Flakon. Made in Switzerland."
path="/"
bareTitle
/>
<SharedNavbar variant="hero" active="atmos" />
<HeroSection
@ -434,6 +446,7 @@ function LandingPage() {
heroImageRef={heroImageRef}
setHeadlinePrimaryRef={setHeadlinePrimaryRef}
setHeadlineSecondaryRef={setHeadlineSecondaryRef}
setHeadlineTertiaryRef={setHeadlineTertiaryRef}
setWordmarkRef={setWordmarkRef}
setDescriptionRef={setDescriptionRef}
setActionsRef={setActionsRef}
@ -442,7 +455,7 @@ function LandingPage() {
overlayTextRef={overlayTextRef}
/>
<main>
<main id="main-content">
<section className="section" id="dufte" data-reveal-group>
<div className="section-heading">
<h2 data-reveal="lines">
@ -452,11 +465,11 @@ function LandingPage() {
</h2>
</div>
<div className="product-grid">
<Grid gap="sm" className="product-grid">
{perfumes.map((item, index) => (
<Link
to={`/duft/${item.slug}`}
className="product-card"
className="product-card col-span-12 md:col-span-6 lg:col-span-4"
key={item.id}
onClick={(event) => startProductTransition(event, item)}
ref={(element) => {
@ -505,7 +518,7 @@ function LandingPage() {
</div>
</Link>
))}
</div>
</Grid>
</section>
<section
@ -534,7 +547,7 @@ function LandingPage() {
<div className="discovery-banner">
<img
src="/atmos-discovery-set-thumbnail.png"
src="/atmos-discovery-set-thumbnail.webp"
alt="Discovery Set"
loading="lazy"
decoding="async"

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import SharedNavbar from "../components/SharedNavbar";
import PageMeta from "../components/seo/PageMeta";
import { formatChf } from "../shop/money";
import { useShop } from "../shop/useShop";
import "./SmallBatchPage.css";
@ -60,9 +61,14 @@ function SmallBatchPage() {
return (
<div className="small-page">
<PageMeta
title="Small Batch · Early Access"
description="atmos Small Batch Programm: limitierte Prototypen, Archivstücke und Early-Access-Düfte für aktive Kund:innen."
path="/small-batch"
/>
<SharedNavbar variant="hero" />
<main className="shell">
<main id="main-content" className="shell">
<section className="small-hero" data-reveal-group>
<span className="small-kicker" data-reveal="fade">
SMALL BATCH / ARCHIVE / PROTOTYPE

View File

@ -1,13 +1,19 @@
import { Link } from "react-router";
import SharedNavbar from "../components/SharedNavbar";
import PageMeta from "../components/seo/PageMeta";
import "./SupportPage.css";
function SupportPage() {
return (
<div className="support-page">
<PageMeta
title="Support"
description="Kontakt, Beratung und Hilfe rund um atmos: Bestellung, Versand, Düfte und Discovery Set."
path="/support"
/>
<SharedNavbar variant="hero" />
<main className="shell">
<main id="main-content" className="shell">
<section className="support-hero" data-reveal-group>
<div className="support-hero-copy">
<span className="support-kicker" data-reveal="fade">

View File

@ -0,0 +1,27 @@
/**
* Breakpoints
* ----------------------------------------------------------------------------
* Mobile-first standard. New layout code must use min-width queries below.
*
* sm >= 480px Phone landscape
* md >= 768px Tablet
* lg >= 1024px Tablet landscape / small desktop
* xl >= 1280px Desktop
*
* Existing legacy max-width queries in page-level CSS files remain in place
* and are migrated incrementally. See plan, Phase E.
*/
/* Visibility utilities */
.is-hidden-md-down {
display: none;
}
@media (min-width: 768px) {
.is-hidden-md-down {
display: revert;
}
.is-hidden-md-up {
display: none;
}
}

View File

@ -0,0 +1,131 @@
/**
* 12-Column Grid System
* ----------------------------------------------------------------------------
* Mobile-first. All columns span 12 by default; `md:` and `lg:` modifiers
* narrow the span on larger viewports.
*
* <Container size="default|narrow|wide">
* <Section spacing="xs|sm|md|lg">
* <Grid cols={12} gap="sm|md|lg">
* <Col span={12} md={6} lg={4} />
*/
/* Containers */
.container,
.container-narrow,
.container-wide {
width: var(--container);
margin-inline: auto;
}
.container-narrow {
width: var(--container-narrow);
}
.container-wide {
width: var(--container-wide);
}
/* Section spacing */
.section-pad-xs {
padding-block: var(--section-y-xs);
}
.section-pad-sm {
padding-block: var(--section-y-sm);
}
.section-pad-md {
padding-block: var(--section-y);
}
.section-pad-lg {
padding-block: var(--section-y-lg);
}
/* Grid */
.grid-12 {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: var(--grid-gap);
}
.grid-gap-sm {
gap: var(--gap-sm);
}
.grid-gap-md {
gap: var(--gap-md);
}
.grid-gap-lg {
gap: var(--gap-lg);
}
/* Base column spans (mobile-first; default = full width) */
.col-span-1 { grid-column: span 1; }
.col-span-2 { grid-column: span 2; }
.col-span-3 { grid-column: span 3; }
.col-span-4 { grid-column: span 4; }
.col-span-5 { grid-column: span 5; }
.col-span-6 { grid-column: span 6; }
.col-span-7 { grid-column: span 7; }
.col-span-8 { grid-column: span 8; }
.col-span-9 { grid-column: span 9; }
.col-span-10 { grid-column: span 10; }
.col-span-11 { grid-column: span 11; }
.col-span-12 { grid-column: span 12; }
.col-start-1 { grid-column-start: 1; }
.col-start-2 { grid-column-start: 2; }
.col-start-3 { grid-column-start: 3; }
.col-start-4 { grid-column-start: 4; }
.col-start-5 { grid-column-start: 5; }
.col-start-6 { grid-column-start: 6; }
.col-start-7 { grid-column-start: 7; }
@media (min-width: 768px) {
.md\:col-span-1 { grid-column: span 1; }
.md\:col-span-2 { grid-column: span 2; }
.md\:col-span-3 { grid-column: span 3; }
.md\:col-span-4 { grid-column: span 4; }
.md\:col-span-5 { grid-column: span 5; }
.md\:col-span-6 { grid-column: span 6; }
.md\:col-span-7 { grid-column: span 7; }
.md\:col-span-8 { grid-column: span 8; }
.md\:col-span-9 { grid-column: span 9; }
.md\:col-span-10 { grid-column: span 10; }
.md\:col-span-11 { grid-column: span 11; }
.md\:col-span-12 { grid-column: span 12; }
.md\:col-start-1 { grid-column-start: 1; }
.md\:col-start-2 { grid-column-start: 2; }
.md\:col-start-3 { grid-column-start: 3; }
.md\:col-start-4 { grid-column-start: 4; }
.md\:col-start-5 { grid-column-start: 5; }
.md\:col-start-6 { grid-column-start: 6; }
.md\:col-start-7 { grid-column-start: 7; }
}
@media (min-width: 1024px) {
.lg\:col-span-1 { grid-column: span 1; }
.lg\:col-span-2 { grid-column: span 2; }
.lg\:col-span-3 { grid-column: span 3; }
.lg\:col-span-4 { grid-column: span 4; }
.lg\:col-span-5 { grid-column: span 5; }
.lg\:col-span-6 { grid-column: span 6; }
.lg\:col-span-7 { grid-column: span 7; }
.lg\:col-span-8 { grid-column: span 8; }
.lg\:col-span-9 { grid-column: span 9; }
.lg\:col-span-10 { grid-column: span 10; }
.lg\:col-span-11 { grid-column: span 11; }
.lg\:col-span-12 { grid-column: span 12; }
.lg\:col-start-1 { grid-column-start: 1; }
.lg\:col-start-2 { grid-column-start: 2; }
.lg\:col-start-3 { grid-column-start: 3; }
.lg\:col-start-4 { grid-column-start: 4; }
.lg\:col-start-5 { grid-column-start: 5; }
.lg\:col-start-6 { grid-column-start: 6; }
.lg\:col-start-7 { grid-column-start: 7; }
}

View File

@ -0,0 +1,126 @@
/**
* Design Tokens
* ----------------------------------------------------------------------------
* Single source of truth for theme colors, spacing, typography, breakpoints,
* grid and motion. Imported once via index.css.
*
* Tokens follow a BEM-like prefix scheme:
* --theme-* colors and surfaces (theme-aware via body.theme-light)
* --gap-* spacing primitives
* --section-* vertical section rhythm
* --container-* horizontal content widths
* --text-* typographic scale
* --bp-* breakpoint values (mobile-first)
* --grid-* layout grid system
* --ease-* motion easing
* --duration-* motion duration
*/
:root {
/* Brand */
--theme-black: #262626;
--theme-white: #eaeaea;
--theme-accent: #ff6a00;
--theme-accent-rgb: 255 106 0;
/* Surfaces (dark theme — default) */
--theme-bg: #262626;
--theme-surface: #2f2f2f;
--theme-surface-soft: #363636;
--theme-paper: #404040;
--theme-text: #eaeaea;
--theme-text-muted: #c8c8c8;
--theme-border: #4a4a4a;
--theme-border-strong: rgba(234, 234, 234, 0.26);
--theme-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
--theme-shadow-soft: 0 16px 42px rgba(0, 0, 0, 0.18);
/* Canonical drop-shadow for product bottle artwork. Used wherever the
flacon has room (hero, recommendation banner, product transition). */
--shadow-product: drop-shadow(0 34px 72px rgba(0, 0, 0, 0.42));
/* Tighter version for small grid contexts (landing product cards,
discovery product thumbnails, recommendation cards) fits inside
the card bounds even with overflow: hidden. */
--shadow-product-card: drop-shadow(0 14px 26px rgba(0, 0, 0, 0.3));
/* Footer keeps its dark identity in both themes */
--footer-bg: #171717;
--footer-text: #f5f5f5;
--footer-text-muted: rgba(255, 255, 255, 0.7);
--footer-text-faint: rgba(255, 255, 255, 0.52);
--footer-border: rgba(255, 255, 255, 0.08);
--footer-watermark: rgba(255, 255, 255, 0.035);
/* Spacing — horizontal page padding */
--page-x: clamp(1rem, 4vw, 5rem);
/* Spacing — vertical section rhythm */
--section-y-xs: clamp(2rem, 5vw, 4.5rem);
--section-y-sm: clamp(3rem, 7vw, 7rem);
--section-y: clamp(4rem, 10vw, 10rem);
--section-y-lg: clamp(5rem, 14vw, 14rem);
/* Containers */
--container: min(calc(100% - (var(--page-x) * 2)), 1440px);
--container-narrow: min(calc(100% - (var(--page-x) * 2)), 920px);
--container-wide: min(calc(100% - (var(--page-x) * 2)), 1680px);
--text-measure: 68ch;
/* Spacing — reusable gaps */
--gap-2xs: clamp(0.35rem, 0.7vw, 0.65rem);
--gap-xs: clamp(0.5rem, 1vw, 0.875rem);
--gap-sm: clamp(0.75rem, 1.5vw, 1.25rem);
--gap-md: clamp(1rem, 2vw, 2rem);
--gap-lg: clamp(1.5rem, 4vw, 4rem);
--gap-xl: clamp(2rem, 6vw, 6rem);
/* Radius — design intent: hard edges everywhere */
--radius-xs: 0;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
/* Typography scale */
--text-xs: clamp(0.75rem, 0.72rem + 0.15vw, 0.875rem);
--text-sm: clamp(0.875rem, 0.83rem + 0.2vw, 1rem);
--text-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
--text-lg: clamp(1.125rem, 1.05rem + 0.35vw, 1.375rem);
--text-xl: clamp(1.35rem, 1.15rem + 0.9vw, 2rem);
--text-2xl: clamp(1.75rem, 1.25rem + 2vw, 3.5rem);
--text-display: clamp(3.05rem, 10.5vw, 10.8rem);
/* Breakpoints (mobile-first, used in JS via matchMedia and as docs) */
--bp-sm: 480px;
--bp-md: 768px;
--bp-lg: 1024px;
--bp-xl: 1280px;
/* Grid system */
--grid-cols: 12;
--grid-gap: var(--gap-md);
/* Accessibility */
--touch-target-min: 44px;
/* Motion */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-snap: cubic-bezier(0.16, 1, 0.3, 1);
--duration-fast: 160ms;
--duration-med: 260ms;
--duration-slow: 720ms;
}
body.theme-light {
--theme-bg: #eaeaea;
--theme-surface: #f5f5f5;
--theme-surface-soft: #f0f0f0;
--theme-paper: #ffffff;
--theme-text: #262626;
--theme-text-muted: #5f5f5f;
--theme-border: #d6d6d6;
--theme-border-strong: rgba(38, 38, 38, 0.22);
--theme-shadow: 0 24px 70px rgba(38, 38, 38, 0.13);
--theme-shadow-soft: 0 16px 42px rgba(38, 38, 38, 0.1);
}