- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Fjora — Taste the North 1.2.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import { StrictMode, useEffect, useId, useState } from "react";
import { createRoot } from "react-dom/client";
import brand from "../public/data/brand.json";
import productData from "../public/data/products.json";
import "./styles.css";
type Product = {
id: string;
name: string;
image: string;
botanicals: string[];
};
const products: Product[] = productData.products;
// Copy written for each flavor, keyed by product id.
const flavorCopy: Record<string, { character: string; note: string; accent: string; tint: string }> = {
"glacier-lime": {
character: "Crisp · Herbal",
note: "Lime peel arrives first, bright and clean. Juniper follows with a cool, resinous line. Arctic thyme settles it all into a dry, herbal finish. Like the first breath outside on a clear morning.",
accent: "#6f8f4e",
tint: "#e7eee0",
},
cloudberry: {
character: "Soft · Floral",
note: "Cloudberry, the color of low winter sun. Chamomile rounds the edges. Rosehip leaves a gentle, tart close. Soft and quiet, and still entirely unsweetened.",
accent: "#c07a32",
tint: "#f5e9dc",
},
"spruce-tip": {
character: "Resinous · Deep",
note: "Green and resinous from the first sip. Young spruce tip meets the earthy depth of angelica. Black currant leaf closes with something wild and a little dark. A forest, in winter, in a glass.",
accent: "#35594a",
tint: "#e0e9e4",
},
};
const faqs = [
{
q: "Is there any sugar in Fjora?",
a: "No. Every flavor is unsweetened. No sugar, and no sweeteners. What you taste comes from the water and the botanicals.",
},
{
q: "Does Fjora contain caffeine?",
a: "No. All three flavors are caffeine-free. Morning, late evening, whenever suits you.",
},
{
q: "What is the bottle made of?",
a: "Single-material glass, closed with an aluminium cap. Both are widely recyclable. Separate the cap from the glass and recycle each in its place.",
},
{
q: "How long is Taste the North available?",
a: "Taste the North is a seasonal line. It is sold while stock lasts. When this season's bottles are gone, the line rests.",
},
];
const navItems = [
{ id: "flavors", label: "Flavors" },
{ id: "story", label: "Story" },
{ id: "faq", label: "FAQ" },
];
/* ---------- Brand graphics ---------- */
function Mark({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 40 40" aria-hidden="true" focusable="false">
<circle cx="20" cy="20" r="18.75" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path
d="M9 21 L16.5 11.5 L20 16 L24 10 L31 21"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path
d="M10 25.5 q2.5 -1.8 5 0 t5 0 t5 0 t5 0 M12 29.5 q2 -1.5 4 0 t4 0 t4 0 t4 0"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
);
}
/** Custom-drawn geometric FJORA letterforms. The A is a bare peak with a wave for a crossbar. */
function Wordmark({ className, title = "Fjora" }: { className?: string; title?: string }) {
const titleId = useId();
return (
<svg className={className} viewBox="-2 -2 186 44" role="img" aria-labelledby={titleId}>
<title id={titleId}>{title}</title>
<g fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square" strokeLinejoin="miter">
{/* F */}
<path d="M1.5 40 V1.5 H22 M1.5 19.5 H17" />
{/* J */}
<path d="M47 1.5 V29 A9.5 9.5 0 0 1 28 29" />
{/* O */}
<circle cx="79" cy="20" r="18.5" />
{/* R */}
<path d="M113.5 40 V1.5 H125 A9.5 9.5 0 0 1 125 20.5 H113.5 M125 20.5 L137 40" />
{/* A */}
<path d="M147 40 L164 1.5 L181 40" strokeLinejoin="round" />
<path d="M156.8 28 q2.4 -2.2 4.8 0 t4.8 0 t4.8 0" strokeLinecap="round" strokeWidth="2.4" />
</g>
</svg>
);
}
/* ---------- Header / navigation ---------- */
function Header() {
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [active, setActive] = useState<string | null>(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const sections = navItems
.map((n) => document.getElementById(n.id))
.filter((el): el is HTMLElement => el !== null);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) setActive(entry.target.id);
}
},
{ rootMargin: "-45% 0px -50% 0px" },
);
sections.forEach((s) => observer.observe(s));
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
return (
<header className={`site-header${scrolled || open ? " is-solid" : ""}`}>
<div className="site-header__inner">
<a className="brand-link" href="#top" aria-label="Fjora, back to top" onClick={() => setOpen(false)}>
<Mark className="brand-link__mark" />
<Wordmark className="brand-link__word" />
</a>
<button
type="button"
className="menu-toggle"
aria-expanded={open}
aria-controls="site-nav"
onClick={() => setOpen((o) => !o)}
>
<span className="menu-toggle__label">{open ? "Close" : "Menu"}</span>
<span className={`menu-toggle__icon${open ? " is-open" : ""}`} aria-hidden="true">
<span />
<span />
</span>
</button>
<nav id="site-nav" className={`site-nav${open ? " is-open" : ""}`} aria-label="Sections">
<ul>
{navItems.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={active === item.id ? "true" : undefined}
onClick={() => setOpen(false)}
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
</div>
</header>
);
}
/* ---------- Sections ---------- */
function Hero() {
return (
<section className="hero" id="top" aria-labelledby="hero-title">
<img
className="hero__image"
src="/assets/hero-fjord.webp"
alt="A still fjord at first light, snow-covered mountains mirrored in the water."
width={1672}
height={941}
fetchPriority="high"
/>
<div className="hero__shade" aria-hidden="true" />
<div className="hero__content">
<Wordmark className="hero__wordmark" title="Fjora" />
<p className="hero__eyebrow">The winter line</p>
<h1 id="hero-title" className="hero__title">
Taste the North
</h1>
<p className="hero__lede">
Glacial meltwater. Cold-steeped botanicals. Three flavors, bottled once this season.
</p>
<a className="button button--light" href="#flavors">
Choose your flavor
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M8 2 V13 M3.5 8.5 L8 13 L12.5 8.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
</svg>
</a>
</div>
<dl className="hero__facts">
<div>
<dt>Flavors</dt>
<dd>03</dd>
</div>
<div>
<dt>Bottled</dt>
<dd>Once per season</dd>
</div>
<div>
<dt>Sugar</dt>
<dd>None</dd>
</div>
</dl>
</section>
);
}
function FlavorSelector() {
const [selectedId, setSelectedId] = useState(products[0].id);
// Only animate the detail area once the visitor has changed flavor, never on first paint.
const [hasChanged, setHasChanged] = useState(false);
const selected = products.find((p) => p.id === selectedId) ?? products[0];
const copy = flavorCopy[selected.id];
const groupName = useId();
return (
<section
className="flavors section"
id="flavors"
aria-labelledby="flavors-title"
style={{ ["--accent" as string]: copy.accent, ["--tint" as string]: copy.tint }}
>
<div className="section__inner">
<header className="section-head section-head--split">
<p className="eyebrow">01 — The flavors</p>
<h2 id="flavors-title">Three flavors for the cold months.</h2>
<p className="section-head__lede">Choose one to see what is inside, and how it tastes.</p>
</header>
<div className="flavors__layout">
<fieldset className="flavor-options">
<legend className="visually-hidden">Choose a flavor</legend>
{products.map((p, i) => {
const isSelected = p.id === selectedId;
return (
<label
key={p.id}
className={`flavor-option${isSelected ? " is-selected" : ""}`}
style={{ ["--option-accent" as string]: flavorCopy[p.id].accent }}
>
<input
className="visually-hidden"
type="radio"
name={groupName}
value={p.id}
checked={isSelected}
onChange={() => {
setSelectedId(p.id);
setHasChanged(true);
}}
/>
<span className="flavor-option__media">
<img src={p.image} alt="" width={1122} height={1402} />
</span>
<span className="flavor-option__text">
<span className="flavor-option__index">0{i + 1}</span>
<span className="flavor-option__name">{p.name}</span>
<span className="flavor-option__character">{flavorCopy[p.id].character}</span>
</span>
</label>
);
})}
</fieldset>
<article className={`flavor-detail${hasChanged ? " is-changing" : ""}`} aria-live="polite" aria-labelledby="flavor-detail-name">
<div className="flavor-detail__media" key={`img-${selected.id}`}>
<img
src={selected.image}
alt={`A bottle of Fjora ${selected.name}, beside its botanicals.`}
width={1122}
height={1402}
/>
</div>
<div className="flavor-detail__body" key={`body-${selected.id}`}>
<p className="flavor-detail__character">{copy.character}</p>
<h3 id="flavor-detail-name" className="flavor-detail__name">
{selected.name}
</h3>
<p className="flavor-detail__note">{copy.note}</p>
<h4 className="flavor-detail__label">Botanicals</h4>
<ul className="botanicals">
{selected.botanicals.map((b) => (
<li key={b}>
<svg viewBox="0 0 12 12" aria-hidden="true" focusable="false">
<path d="M6 1 C9 3.5 9 8.5 6 11 C3 8.5 3 3.5 6 1 Z M6 3 V11" fill="none" stroke="currentColor" strokeWidth="1" />
</svg>
{b}
</li>
))}
</ul>
<ul className="facts-row" aria-label="In every bottle">
<li>Unsweetened</li>
<li>Caffeine-free</li>
<li>Glass & aluminium</li>
</ul>
</div>
</article>
</div>
</div>
</section>
);
}
function Story() {
return (
<section className="story section" id="story" aria-labelledby="story-title">
<div className="section__inner story__layout">
<figure className="story__figure">
<img
src="/assets/story-botanicals.webp"
alt="Spruce, cloudberries, lime peel and juniper berries on a frosted shoreline rock."
width={1122}
height={1402}
/>
<figcaption>Spruce, cloudberry, lime peel, juniper. Gathered cold.</figcaption>
</figure>
<div className="story__text">
<p className="eyebrow">02 — Our story</p>
<h2 id="story-title">Made slowly, where the water runs cold.</h2>
<p>
Fjora begins with glacial meltwater. Clear, cold, and patient. It is the quiet base for
everything we bottle.
</p>
<p>
We steep our botanicals cold, never hot. It takes longer. It keeps what is delicate: the
peel, the petal, the needle.
</p>
<p>
Taste the North is our winter line. Three flavors, bottled once per season. Nothing added
to fill the silence.
</p>
<ol className="pillars">
<li>
<span className="pillars__num">i</span>
<span className="pillars__title">Glacial meltwater</span>
<span className="pillars__text">A cold, clear beginning.</span>
</li>
<li>
<span className="pillars__num">ii</span>
<span className="pillars__title">Cold-steeped</span>
<span className="pillars__text">Botanicals given time.</span>
</li>
<li>
<span className="pillars__num">iii</span>
<span className="pillars__title">Once per season</span>
<span className="pillars__text">One bottling each winter.</span>
</li>
</ol>
</div>
</div>
</section>
);
}
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
const id = useId();
return (
<li className={`faq-item${open ? " is-open" : ""}`}>
<h3 className="faq-item__heading">
<button
type="button"
id={`${id}-button`}
aria-expanded={open}
aria-controls={`${id}-panel`}
onClick={() => setOpen((o) => !o)}
>
<span>{q}</span>
<span className="faq-item__icon" aria-hidden="true" />
</button>
</h3>
<div
className="faq-item__panel"
id={`${id}-panel`}
role="region"
aria-labelledby={`${id}-button`}
hidden={!open}
>
<p>{a}</p>
</div>
</li>
);
}
function Faq() {
return (
<section className="faq section" id="faq" aria-labelledby="faq-title">
<div className="section__inner faq__layout">
<header className="section-head">
<p className="eyebrow">03 — Questions</p>
<h2 id="faq-title">Plain answers.</h2>
<p className="section-head__lede">What is in the bottle, and what is not.</p>
</header>
<ul className="faq-list">
{faqs.map((f) => (
<FaqItem key={f.q} q={f.q} a={f.a} />
))}
</ul>
</div>
</section>
);
}
function Footer() {
return (
<footer className="site-footer">
<div className="section__inner site-footer__inner">
<div className="site-footer__brand">
<Mark className="site-footer__mark" />
<Wordmark className="site-footer__word" />
<p>{brand.campaign}. The winter line.</p>
</div>
<nav aria-label="Footer">
<ul>
{navItems.map((item) => (
<li key={item.id}>
<a href={`#${item.id}`}>{item.label}</a>
</li>
))}
<li>
<a href="#top">Top</a>
</li>
</ul>
</nav>
<address className="site-footer__contact">{brand.footer_contact}</address>
<p className="site-footer__notice">{brand.fictional_notice}</p>
</div>
</footer>
);
}
function App() {
return (
<>
<a className="skip-link" href="#flavors">
Skip to flavors
</a>
<Header />
<main>
<Hero />
<FlavorSelector />
<Story />
<Faq />
</main>
<Footer />
</>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);