From 73fcc0548ce97912f5f09efd0c0b51b1b23359bd Mon Sep 17 00:00:00 2001 From: jzzliu Date: Sat, 7 Nov 2026 21:18:38 -0700 Subject: [PATCH] Rework storefront into modules and tokenised styles Split the 100-line script into catalog, cart, format, dom and main modules. Money is integer cents, the bag is capped and sanitised and syncs across tabs, and the view (category, sort) lives in the URL. Templates escape every interpolation. Rewrite the minified stylesheet as cascade layers on design tokens. Fixes heading tracking that collapsed word gaps, a 2.6:1 focus ring on the hero, 1.3:1 control borders, misaligned cart quantities, uneven product cards and undersized touch targets. Adds a favicon and a resolvable dialog label. --- dist/favicon.svg | 1 + dist/index.html | 87 ++++++-- dist/js/cart.js | 64 ++++++ dist/js/catalog.js | 94 +++++++++ dist/js/dom.js | 50 +++++ dist/js/format.js | 20 ++ dist/js/main.js | 273 +++++++++++++++++++++++++ dist/shop.js | 100 --------- dist/style.css | 498 ++++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 1071 insertions(+), 116 deletions(-) create mode 100644 dist/favicon.svg create mode 100644 dist/js/cart.js create mode 100644 dist/js/catalog.js create mode 100644 dist/js/dom.js create mode 100644 dist/js/format.js create mode 100644 dist/js/main.js delete mode 100644 dist/shop.js diff --git a/dist/favicon.svg b/dist/favicon.svg new file mode 100644 index 0000000..ec0fb05 --- /dev/null +++ b/dist/favicon.svg @@ -0,0 +1 @@ + diff --git a/dist/index.html b/dist/index.html index 6d1e588..22c53fa 100644 --- a/dist/index.html +++ b/dist/index.html @@ -1,27 +1,84 @@ - - + + Fieldwork Supply — Equipment for getting out - + + + + + + + + +
FIELDWORK SUPPLY   /   A FICTIONAL STOREFRONT
-
Fieldwork.
+
+ Fieldwork. + + +
-
-

THE TRAIL COLLECTION / 01

Better
outside.

Thoughtfully designed gear for slow mornings, long trails, and weekends out of range.

Explore the collection
-
Illustrated mountain range and pine forest at sunsetFIG. 01   /   ABOVE THE TREELINE
-
-

01 / THE SHOP

Trail essentials

Pack light. Make it count.

-
-

-

02 / THE FIELD NOTES

Good gear leaves room for the good stuff.

Fieldwork is an imagined gear shop built around a simple packing list. Six essentials, each with a clear job, for getting out the door and into the day.

Back to the collection ↑
+
+
+

THE TRAIL COLLECTION / 01

+

Better
outside.

+

Thoughtfully designed gear for slow mornings, long trails, and weekends out of range.

+ Explore the collection +
+
+ Illustrated mountain range and pine forest at sunset + +
+
+ +
+
+

01 / THE SHOP

Trail essentials

+

Pack light. Make it count.

+
+
+
+ +
+

+
+ +
+ +
+

02 / THE FIELD NOTES

+

Good gear leaves room for the good stuff.

+

Fieldwork is an imagined gear shop built around a simple packing list. Six essentials, each with a clear job, for getting out the door and into the day.

+ Back to the collection ↑ +
- -

FIELDWORK / EQUIPMENT

-

Your bag

+ + + +

FIELDWORK / EQUIPMENT

+
+
+ + +

Your bag

+
+ + +

+
+
diff --git a/dist/js/cart.js b/dist/js/cart.js new file mode 100644 index 0000000..5034d35 --- /dev/null +++ b/dist/js/cart.js @@ -0,0 +1,64 @@ +/** + * Shopping bag logic. A cart is a plain `{ [productId]: quantity }` object. + * + * Everything here is pure (no DOM, no globals) except load/save, which take the storage object + * as an argument so they can be tested with a fake, including one that throws. + */ + +export const MAX_QTY = 9; +export const STORAGE_KEY = "fieldwork-bag-v1"; // format unchanged, so existing bags carry over + +const clamp = (n) => Math.min(MAX_QTY, Math.max(0, n)); + +/** Keep only known products with whole-number quantities, clamped to 1..MAX_QTY. */ +export function sanitizeCart(raw, validIds) { + const clean = {}; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return clean; + for (const id of validIds) { + const qty = raw[id]; + if (Number.isSafeInteger(qty) && qty > 0) clean[id] = clamp(qty); + } + return clean; +} + +/** Return a new cart with `delta` added to one line. Lines at 0 are removed. */ +export function changeQty(cart, id, delta) { + const next = { ...cart }; + const qty = clamp((cart[id] || 0) + delta); + if (qty === 0) delete next[id]; + else next[id] = qty; + return next; +} + +export const cartCount = (cart) => Object.values(cart).reduce((sum, qty) => sum + qty, 0); + +/** Lines in catalogue order: [{ product, qty, total }] with totals in cents. */ +export function cartLines(cart, products) { + return products + .filter((product) => cart[product.id]) + .map((product) => ({ + product, + qty: cart[product.id], + total: product.price * cart[product.id], + })); +} + +export const cartSubtotal = (cart, products) => + cartLines(cart, products).reduce((sum, line) => sum + line.total, 0); + +export function loadCart(storage, validIds) { + try { + return sanitizeCart(JSON.parse(storage.getItem(STORAGE_KEY)), validIds); + } catch { + return {}; // storage blocked, empty, or corrupted JSON + } +} + +export function saveCart(storage, cart) { + try { + if (cartCount(cart) === 0) storage.removeItem(STORAGE_KEY); + else storage.setItem(STORAGE_KEY, JSON.stringify(cart)); + } catch { + /* private mode or quota exceeded: the bag still works for this visit */ + } +} diff --git a/dist/js/catalog.js b/dist/js/catalog.js new file mode 100644 index 0000000..a885b05 --- /dev/null +++ b/dist/js/catalog.js @@ -0,0 +1,94 @@ +/** Product data and the pure functions that filter, sort and (de)serialise the shop view. */ + +const freeze = Object.freeze; + +export const PRODUCTS = freeze( + [ + { + id: "pack", + name: "Ridgeline Pack", + category: "Carry", + price: 8600, + alt: "Illustration of an olive roll-top daypack with a front pocket", + detail: "A compact daypack with a roll-top closure and an easy-access front pocket.", + }, + { + id: "mug", + name: "Camp Enamel Mug", + category: "Camp", + price: 2400, + alt: "Illustration of a rust-red enamel mug with a dark rim", + detail: "An enamel-coated camp mug with a generous handle and a dark rolled rim.", + }, + { + id: "cap", + name: "Switchback Cap", + category: "Wear", + price: 3200, + alt: "Illustration of an olive five-panel cap", + detail: "A soft five-panel cap with an adjustable back for sunny walks and early starts.", + }, + { + id: "bottle", + name: "Alpine Flask", + category: "Carry", + price: 3800, + alt: "Illustration of a slate-green insulated flask with a carry loop", + detail: "A narrow insulated flask with a screw-top lid and a carry loop.", + }, + { + id: "lantern", + name: "Afterglow Lantern", + category: "Camp", + price: 5400, + alt: "Illustration of a lantern with a warm glowing diffuser", + detail: "A compact lantern with a warm diffuser and a folding carry handle.", + }, + { + id: "blanket", + name: "Meadow Blanket", + category: "Camp", + price: 6800, + alt: "Illustration of a striped camp blanket rolled up", + detail: "A striped camp blanket that rolls down neatly for a picnic or a cool evening.", + }, + ].map(freeze), +); + +export const PRODUCTS_BY_ID = new Map(PRODUCTS.map((p) => [p.id, p])); +export const CATEGORIES = freeze(["All", ...new Set(PRODUCTS.map((p) => p.category))]); +export const SORTS = freeze({ + featured: "Featured", + low: "Price: low to high", + high: "Price: high to low", +}); + +export const DEFAULT_VIEW = freeze({ category: "All", sort: "featured" }); + +/** Filter by category and sort by price. Never mutates its input; ties keep catalogue order. */ +export function queryProducts(products, { category = "All", sort = "featured" } = {}) { + const list = category === "All" ? [...products] : products.filter((p) => p.category === category); + if (sort === "low") list.sort((a, b) => a.price - b.price); + else if (sort === "high") list.sort((a, b) => b.price - a.price); + return list; +} + +/** Parse `?category=Camp&sort=low`. Anything unrecognised falls back to the default. */ +export function readView(search) { + const params = new URLSearchParams(search); + const category = params.get("category"); + const sort = params.get("sort"); + return { + category: CATEGORIES.includes(category) ? category : DEFAULT_VIEW.category, + sort: Object.keys(SORTS).includes(sort) ? sort : DEFAULT_VIEW.sort, + }; +} + +/** Inverse of readView. Defaults are omitted so the plain shop URL stays clean. */ +export function viewToSearch(view) { + const params = new URLSearchParams(); + if (view.category !== DEFAULT_VIEW.category) params.set("category", view.category); + if (view.sort !== DEFAULT_VIEW.sort) params.set("sort", view.sort); + const query = params.toString(); + return query ? `?${query}` : ""; +} diff --git a/dist/js/dom.js b/dist/js/dom.js new file mode 100644 index 0000000..1b0a3c5 --- /dev/null +++ b/dist/js/dom.js @@ -0,0 +1,50 @@ +/** + * Tiny HTML templating with automatic escaping. + * + * Every interpolated value is escaped unless it is itself the result of `html` (or `raw`), + * so product names, ids and any future user-supplied text can never inject markup. + * + * html`

${name}

` // name is escaped + * html`` // nested templates are not double-escaped + */ + +class SafeHtml { + constructor(value) { + this.value = value; + } + toString() { + return this.value; + } +} + +const ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + +export function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (ch) => ESCAPES[ch]); +} + +function toHtml(value) { + if (value instanceof SafeHtml) return value.value; + if (Array.isArray(value)) return value.map(toHtml).join(""); + // null/undefined render as nothing. `false` deliberately renders as "false" so boolean + // attributes such as aria-pressed="${isActive}" work; use `cond ? x : ""` for optional chunks. + if (value === null || value === undefined) return ""; + return escapeHtml(value); +} + +/** Mark a trusted string (e.g. a static attribute) as already-safe markup. */ +export const raw = (value) => new SafeHtml(String(value)); + +export function html(strings, ...values) { + let out = strings[0]; + for (let i = 0; i < values.length; i++) out += toHtml(values[i]) + strings[i + 1]; + return new SafeHtml(out); +} + +/** Replace an element's contents. Only accepts `html` results, never plain strings. */ +export function mount(element, template) { + if (!(template instanceof SafeHtml)) { + throw new TypeError("mount() needs an html`...` template; plain strings are refused"); + } + element.innerHTML = template.value; +} diff --git a/dist/js/format.js b/dist/js/format.js new file mode 100644 index 0000000..a1509d5 --- /dev/null +++ b/dist/js/format.js @@ -0,0 +1,20 @@ +/** Formatting helpers. Money is always handled as integer cents to avoid float drift. */ + +// Intl formatters are comparatively expensive to build, so create each one once. +const whole = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); +const exact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); + +/** 8600 -> "$86", 8650 -> "$86.50" */ +export function money(cents) { + return (cents % 100 === 0 ? whole : exact).format(cents / 100); +} + +/** plural(1, "item") -> "1 item", plural(3, "item") -> "3 items" */ +export function plural(count, noun, pluralNoun = `${noun}s`) { + return `${count} ${count === 1 ? noun : pluralNoun}`; +} diff --git a/dist/js/main.js b/dist/js/main.js new file mode 100644 index 0000000..5cccd1e --- /dev/null +++ b/dist/js/main.js @@ -0,0 +1,273 @@ +/** + * Fieldwork storefront: wires the pure modules (catalog, cart) to the page. + * + * Structure: state at the top, view functions that render from state, then one delegated click + * handler that dispatches `data-action` attributes to the `actions` table below. + */ +import { + CATEGORIES, + PRODUCTS, + PRODUCTS_BY_ID, + SORTS, + queryProducts, + readView, + viewToSearch, +} from "./catalog.js"; +import { + MAX_QTY, + STORAGE_KEY, + cartCount, + cartLines, + cartSubtotal, + changeQty, + loadCart, + saveCart, +} from "./cart.js"; +import { html, mount, raw } from "./dom.js"; +import { money, plural } from "./format.js"; + +const $ = (id) => document.getElementById(id); + +/* ------------------------------------------------------------------ state */ + +const storage = (() => { + try { + return window.localStorage; + } catch { + return null; // blocked by browser settings; the bag then lasts for this visit only + } +})(); +const validIds = [...PRODUCTS_BY_ID.keys()]; + +let view = readView(location.search); // { category, sort }, mirrored in the URL +let cart = loadCart(storage, validIds); // { [productId]: quantity } +let toastTimer; + +/* ------------------------------------------------------------------ views */ + +const productCard = (product, index) => html` +
+ +
+
+

${product.category}

+

${product.name}

+

${money(product.price)}

+
+ +
+
`; + +const cartRow = ({ product, qty, total }) => html` +
+ +
+ ${product.name} +

${money(total)}

+
+
+ + ${qty} + +
+
`; + +const emptyBag = html` +

Your bag is empty.

+ `; + +function renderControls() { + mount( + $("filters"), + html`${CATEGORIES.map( + (name) => html``, + )}`, + ); + mount( + $("sort"), + html`${Object.entries(SORTS).map( + ([value, label]) => html``, + )}`, + ); + syncControls(); +} + +/** Update pressed state in place so keyboard focus is never lost on a filter click. */ +function syncControls() { + for (const button of $("filters").children) { + button.setAttribute("aria-pressed", String(button.dataset.value === view.category)); + } + $("sort").value = view.sort; +} + +function renderProducts() { + const items = queryProducts(PRODUCTS, view); + mount($("products"), html`${items.map(productCard)}`); + $("results").textContent = `${plural(items.length, "product")} shown`; +} + +function renderCart() { + const lines = cartLines(cart, PRODUCTS); + const count = cartCount(cart); + $("count").textContent = count; + $("bag").setAttribute("aria-label", `Open bag, ${plural(count, "item")}`); + mount($("cart-items"), lines.length ? html`${lines.map(cartRow)}` : emptyBag); + $("subtotal").textContent = money(cartSubtotal(cart, PRODUCTS)); + $("cart-summary").hidden = lines.length === 0; +} + +/* ------------------------------------------------------------------ helpers */ + +function setCart(next) { + cart = next; + saveCart(storage, cart); + renderCart(); +} + +function commitView() { + try { + history.replaceState(null, "", location.pathname + viewToSearch(view) + location.hash); + } catch { + /* e.g. sandboxed frame: the shop still works, the URL just is not updated */ + } + syncControls(); + renderProducts(); +} + +function notify(message) { + $("toast").textContent = message; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => ($("toast").textContent = ""), 2600); +} + +function openBag() { + $("order").hidden = true; + $("cart-items").hidden = false; + $("cart-status").textContent = ""; + renderCart(); + $("cart").showModal(); +} + +const orderRef = () => + `FW-${String(crypto.getRandomValues(new Uint32Array(1))[0] % 100000).padStart(5, "0")}`; + +/* ------------------------------------------------------------------ actions */ + +const actions = { + filter({ value }) { + view = { ...view, category: value }; + commitView(); + }, + + open({ id }) { + const product = PRODUCTS_BY_ID.get(id); + if (!product) return; + mount( + $("product-detail"), + html` + ${product.alt} +

${product.name}

+

${product.detail}

+ `, + ); + // The title is rendered above, so the label reference is only valid from this point on. + $("product-dialog").setAttribute("aria-labelledby", "product-title"); + $("product-dialog").showModal(); + }, + + add({ id }) { + const product = PRODUCTS_BY_ID.get(id); + if (!product) return; + if ((cart[id] || 0) >= MAX_QTY) { + notify(`Limit of ${MAX_QTY} per item`); + return; + } + setCart(changeQty(cart, id, 1)); + if ($("product-dialog").open) $("product-dialog").close(); + notify(`${product.name} added · ${plural(cartCount(cart), "item")} in bag`); + }, + + adjust({ id, step }) { + setCart(changeQty(cart, id, Number(step))); + const name = PRODUCTS_BY_ID.get(id).name; + $("cart-status").textContent = cart[id] ? `${name}: ${cart[id]} in bag` : `${name} removed`; + // The row was re-rendered: put focus back on the same control (or its sibling if that one + // is now disabled at the quantity limit, or the close button if the row is gone). + const find = (s) => + $("cart-items").querySelector( + `[data-action="adjust"][data-id="${CSS.escape(id)}"][data-step="${s}"]`, + ); + const target = [step, step === "1" ? "-1" : "1"].map(find).find((b) => b && !b.disabled); + (target || $("cart").querySelector(".close")).focus(); + }, + + "open-bag": openBag, + + "close-cart"() { + $("cart").close(); + }, + + checkout() { + const count = cartCount(cart); + if (!count) return; + const total = cartSubtotal(cart, PRODUCTS); + setCart({}); + $("cart-items").hidden = true; + $("order").hidden = false; + $("order").textContent = + `Demo order ${orderRef()} placed: ${plural(count, "item")}, ${money(total)}. ` + + "Thanks for exploring Fieldwork."; + $("order").focus(); + }, +}; + +/* ------------------------------------------------------------------ events */ + +document.addEventListener("click", (event) => { + const control = event.target.closest("[data-action]"); + if (control && Object.hasOwn(actions, control.dataset.action)) { + actions[control.dataset.action](control.dataset, control); + return; + } + // Clicking the backdrop (outside the dialog's own box) closes a modal dialog. + const dialog = event.target; + if (dialog instanceof HTMLDialogElement && dialog.open) { + const box = dialog.getBoundingClientRect(); + const inside = + event.clientX >= box.left && + event.clientX <= box.right && + event.clientY >= box.top && + event.clientY <= box.bottom; + if (!inside) dialog.close(); + } +}); + +$("sort").addEventListener("change", (event) => { + view = { ...view, sort: event.target.value }; + commitView(); +}); + +// Keep the bag in sync when it changes in another tab or window. +window.addEventListener("storage", (event) => { + if (event.key === STORAGE_KEY || event.key === null) { + cart = loadCart(storage, validIds); + renderCart(); + } +}); + +/* ------------------------------------------------------------------ start */ + +renderControls(); +renderProducts(); +renderCart(); diff --git a/dist/shop.js b/dist/shop.js deleted file mode 100644 index 6f9933e..0000000 --- a/dist/shop.js +++ /dev/null @@ -1,100 +0,0 @@ -"use strict"; -const catalog = [ - {id:"pack",name:"Ridgeline Pack",category:"Carry",price:86,detail:"A compact daypack with a roll-top closure and an easy-access front pocket."}, - {id:"mug",name:"Camp Enamel Mug",category:"Camp",price:24,detail:"An enamel-coated camp mug with a generous handle and a dark rolled rim."}, - {id:"cap",name:"Switchback Cap",category:"Wear",price:32,detail:"A soft five-panel cap with an adjustable back for sunny walks and early starts."}, - {id:"bottle",name:"Alpine Flask",category:"Carry",price:38,detail:"A narrow insulated flask with a screw-top lid and a carry loop."}, - {id:"lantern",name:"Afterglow Lantern",category:"Camp",price:54,detail:"A compact lantern with a warm diffuser and a folding carry handle."}, - {id:"blanket",name:"Meadow Blanket",category:"Camp",price:68,detail:"A striped camp blanket that rolls down neatly for a picnic or a cool evening."} -]; -const byId = id => document.getElementById(id); -const money = value => new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:0}).format(value); -const product = id => catalog.find(item => item.id === id); -const art = item => '' + item.name + ' product illustration'; -const storageKey = "fieldwork-bag-v1"; -let bag = {}; -try { - const stored = JSON.parse(localStorage.getItem(storageKey)) || {}; - for (const {id} of catalog) { - if (Number.isSafeInteger(stored[id]) && stored[id] > 0) bag[id] = stored[id]; - } -} catch {} -let filter = "All"; -let toastTimer; -function renderProducts() { - const items = catalog.filter(item => filter === "All" || item.category === filter); - const order = byId("sort").value; - if (order !== "featured") items.sort((a,b) => order === "low" ? a.price-b.price : b.price-a.price); - byId("products").innerHTML = items.map(item => `
- -

${item.category}

${item.name}

${money(item.price)}

-
`).join(""); - byId("results").textContent = `${items.length} products shown`; -} -function renderCart() { - const items = catalog.filter(item => bag[item.id]); - byId("count").textContent = items.reduce((sum,item) => sum + bag[item.id],0); - byId("cart-items").innerHTML = items.map(item => `
${art(item)} -
${item.name}

${money(item.price * bag[item.id])}

-
- ${bag[item.id]}
`).join("") || "

Your bag is empty.

"; - byId("subtotal").textContent = money(items.reduce((sum,item) => sum + item.price * bag[item.id],0)); - byId("cart-summary").hidden = !items.length; -} -function updateBag(id, amount) { - bag[id] = (bag[id] || 0) + amount; - if (bag[id] <= 0) delete bag[id]; - try {localStorage.setItem(storageKey,JSON.stringify(bag));} catch {} - renderCart(); -} -function notify(message) { - byId("toast").textContent = message; - clearTimeout(toastTimer); - toastTimer = setTimeout(() => byId("toast").textContent = "",2400); -} -byId("filters").innerHTML = ["All",...new Set(catalog.map(item => item.category))] - .map(category => ``).join(""); -byId("filters").addEventListener("click",event => { - const button = event.target.closest("[data-category]"); - if (!button) return; - filter = button.dataset.category; - for (const option of byId("filters").children) option.setAttribute("aria-pressed", option === button); - renderProducts(); -}); -byId("sort").addEventListener("change",renderProducts); -byId("bag").addEventListener("click",() => { - byId("order").hidden = true; - byId("cart-items").hidden = false; - byId("cart").showModal(); -}); -document.addEventListener("click",event => { - const button = event.target.closest("button"); - if (!button) return; - const {add,detail,adjust,step} = button.dataset; - if (add) { - updateBag(add,1); - if (byId("product-dialog").open) byId("product-dialog").close(); - notify(`${product(add).name} added to your bag`); - } - if (detail) { - const item = product(detail); - byId("product-detail").innerHTML = `${art(item)}

${item.name}

${item.detail}

- `; - byId("product-dialog").showModal(); - } - if (adjust) { - updateBag(adjust,Number(step)); - (byId("cart-items").querySelector(`[data-adjust="${adjust}"][data-step="${step}"]`) || byId("cart").querySelector(".close")).focus(); - } -}); -byId("checkout").addEventListener("click",() => { - for (const id of Object.keys(bag)) delete bag[id]; - try {localStorage.removeItem(storageKey);} catch {} - renderCart(); - byId("cart-items").hidden = true; - byId("order").textContent = "Demo order placed. Thanks for exploring Fieldwork."; - byId("order").hidden = false; - byId("cart").querySelector(".close").focus(); -}); -renderProducts(); -renderCart(); diff --git a/dist/style.css b/dist/style.css index 1ad7e40..6771500 100644 --- a/dist/style.css +++ b/dist/style.css @@ -1 +1,497 @@ -:root{--paper:#f7f5ed;--ink:#1d2924;--forest:#20392d;--lime:#e2f596;--line:#d8dbce;--muted:#50594e;--mono:ui-monospace,monospace}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:system-ui,sans-serif}button,select{font:inherit;color:inherit}button,a,select{touch-action:manipulation}button{cursor:pointer}a{color:inherit;text-decoration:none}a:focus-visible,button:focus-visible,select:focus-visible{outline:3px solid #527e49;outline-offset:4px}p,h1,h2,h3{margin:0}.notice{text-align:center;padding:.62rem;background:var(--lime);font:700 .67rem var(--mono);letter-spacing:.12em}.top,main,footer{width:min(1320px,calc(100% - 8vw));margin:auto}.top{height:78px;display:flex;align-items:center;justify-content:space-between;gap:1rem}.wordmark{font-size:2rem;font-weight:850;letter-spacing:-.085em}.wordmark span{color:#53794b}.top nav{display:flex;gap:2rem;font-size:.92rem}.top nav a:hover,footer a:hover{text-decoration:underline}.bag,.close{border:1px solid var(--line);background:transparent;border-radius:30px;padding:.5rem .85rem}.bag span{display:inline-grid;place-items:center;background:var(--forest);color:white;border-radius:50%;width:1.4rem;height:1.4rem;font-size:.75rem;margin-left:.35rem}.hero{display:grid;grid-template-columns:43% 57%;min-height:520px;background:var(--forest);color:#fbfaf0}.hero-copy{padding:clamp(2rem,5.4vw,6rem);display:flex;align-items:flex-start;justify-content:center;flex-direction:column;gap:1.3rem}.eyebrow{font:700 .72rem var(--mono);letter-spacing:.12em}.hero .eyebrow{color:var(--lime)}.eyebrow i{display:inline-block;width:.55rem;height:.55rem;border-radius:50%;background:currentColor;margin-right:.5rem}.hero h1{font-size:clamp(4rem,8.4vw,8.8rem);line-height:.79;letter-spacing:-.085em}.hero h1 span{color:var(--lime)}.hero-copy>p:not(.eyebrow){color:#e1e6d9;max-width:29ch;line-height:1.5}.primary{display:inline-flex;align-items:center;justify-content:space-between;gap:1rem;background:var(--lime);color:var(--ink);border:0;padding:.9rem 1.15rem;font-weight:750}.primary:hover{background:#d3ea78}.hero-art{position:relative;min-height:100%;overflow:hidden}.hero-art img{display:block;width:100%;height:100%;object-fit:cover;position:absolute}.image-label{position:absolute;bottom:1rem;right:1rem;background:#192c24;color:#fff;padding:.55rem .75rem;font:.66rem var(--mono);letter-spacing:.07em}.shop{padding:4.5rem 0}.shop-heading{display:flex;align-items:end;justify-content:space-between;gap:1rem;margin-bottom:1.6rem}.shop h2,.story h2{font-size:clamp(2.6rem,5vw,5rem);line-height:.95;letter-spacing:-.07em;margin-top:.55rem}.shop-heading>p{color:var(--muted)}.controls{display:flex;justify-content:space-between;align-items:center;gap:.8rem;flex-wrap:wrap;border-block:1px solid var(--line);padding:.85rem 0;margin-bottom:1.2rem}.controls label{display:flex;gap:.5rem;align-items:center;font-size:.85rem}.controls select{border:1px solid var(--line);background:transparent;padding:.45rem}.controls button{border:1px solid var(--line);background:transparent;border-radius:24px;padding:.45rem .85rem;margin:.15rem .3rem .15rem 0;font-size:.83rem}.controls button[aria-pressed=true]{background:var(--forest);color:white;border-color:var(--forest)}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1.8rem 1rem}.product{min-width:0}.product-image{display:block;width:100%;padding:0;border:0;background:transparent}.product-image img,#product-detail img,.cart-row img{display:block;width:100%;height:auto}.product-image img{transition:filter .2s}.product-image:hover img{filter:brightness(.94)}.product-info{display:grid;grid-template-columns:1fr auto;align-items:end;gap:.45rem;padding:.85rem 0}.product h3{font-size:clamp(1rem,2vw,1.3rem);letter-spacing:-.035em}.product .eyebrow{font-size:.65rem;color:var(--muted);margin-bottom:.35rem}.add{background:transparent;border:1px solid var(--line);width:2.6rem;height:2.6rem;font-size:1.3rem}.add:hover{background:var(--lime)}.price{font:700 .83rem var(--mono);margin-top:.3rem}.story{background:#e4e7d9;padding:clamp(2rem,6vw,6rem)}.story h2{max-width:15ch}.story>p:not(.eyebrow){max-width:53ch;line-height:1.65;margin:1.25rem 0}.story a,footer>a:last-child{text-decoration:underline;text-underline-offset:4px}footer{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:2.5rem 0;font-size:.8rem}dialog{background:var(--paper);color:var(--ink);border:1px solid var(--line);max-width:min(520px,calc(100% - 2rem));width:100%;max-height:90dvh;padding:1.3rem;overflow:auto}dialog::backdrop{background:#101b16aa}#cart{margin:0 0 0 auto;max-height:100dvh;height:100dvh;max-width:min(470px,100%);border:0}.dialog-head,.subtotal{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1rem}.close{font-size:1.35rem;line-height:1}.cart-row{display:grid;grid-template-columns:75px 1fr auto;align-items:center;gap:.8rem;padding:.75rem 0;border-bottom:1px solid var(--line)}.qty{display:flex;align-items:center;gap:.4rem}.qty button{background:transparent;border:1px solid var(--line);width:1.8rem;height:1.8rem}.subtotal{margin-top:1rem}.fine{font-size:.78rem;color:var(--muted);margin-top:.8rem}#product-detail h2{margin-top:1rem}#product-detail>p{margin:.65rem 0 1rem;line-height:1.5}#toast{position:fixed;right:1rem;bottom:1rem;background:var(--forest);color:white;padding:.8rem 1rem;z-index:3}#toast:empty{display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}[hidden]{display:none!important}@media(max-width:760px){.hero{grid-template-columns:1fr;min-height:0}.hero-art{min-height:310px}.hero h1{font-size:clamp(4.2rem,14vw,6.5rem)}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.shop{padding:3rem 0}.top nav{gap:.75rem}.top{height:65px}}@media(max-width:420px){.top nav a:last-child{display:none}.shop-heading>p{display:none}.product-info{gap:.2rem}footer{flex-wrap:wrap}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}} +/* + * Fieldwork Supply storefront. + * + * Organisation: cascade layers, lowest priority first (reset < tokens < base < layout < + * components < motion < utilities), so rules never fight over specificity. + * + * Design language + * - Pills (999px) for navigation and toggles: bag, close, filter chips. + * - Square corners for actions and inputs: primary buttons, add, quantity, select. + * - Mono caps for labels and prices; tight, bold sans for display headings. + * - Every colour, size and space comes from a token below; no one-off values in components. + * + * Contrast (WCAG): --ink 13.8:1, --muted 6.7:1 on paper (5.8:1 on --sage), --lime on --forest + * 10.5:1, --focus 5.7:1 on paper, --control 3.4:1 on paper (UI components need 3:1). + */ + +@layer reset, tokens, base, layout, components, motion, utilities; + +/* ------------------------------------------------------------------ reset */ +@layer reset { + *, *::before, *::after { box-sizing: border-box; } + body, h1, h2, h3, p { margin: 0; } + img { display: block; max-width: 100%; height: auto; } + button, select { font: inherit; color: inherit; } + button { cursor: pointer; } + button, a, select { touch-action: manipulation; } + a { color: inherit; text-decoration: none; } +} + +/* ------------------------------------------------------------------ tokens */ +@layer tokens { + :root { + color-scheme: light; + + /* colour */ + --paper: #f7f5ed; + --sage: #e4e7d9; /* story section */ + --ink: #1d2924; + --muted: #50594e; + --forest: #20392d; + --forest-deep: #192c24; /* image caption chip */ + --lime: #e2f596; + --lime-strong: #d3ea78; /* lime hover */ + --leaf: #53794b; /* wordmark full stop */ + --line: #d8dbce; /* decorative dividers only */ + --control: #808876; /* borders of interactive controls */ + --on-dark: #fbfaf0; + --on-dark-soft: #e1e6d9; + --focus: #3d6b35; + --scrim: rgb(16 27 22 / .67); + + /* type */ + --font-sans: system-ui, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; + --text-label: .72rem; + --text-small: .875rem; + --text-base: 1rem; + --text-title: clamp(1.05rem, .6rem + 1.6vw, 1.3rem); + --text-h2: clamp(2.5rem, 5vw, 4.75rem); + --text-h1: clamp(4rem, 8.4vw, 8.5rem); + --track-display: -.05em; + --track-label: .12em; + + /* space */ + --space-1: .25rem; + --space-2: .5rem; + --space-3: .75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-7: 3rem; + --space-8: 4.5rem; + --page: min(1320px, 100% - 8vw); + --target: 2.5rem; /* minimum control size; 44px on touch screens, see below */ + + /* shape, depth, motion */ + --radius-pill: 999px; + --shadow-float: 0 10px 30px rgb(16 27 22 / .22); + --ease: cubic-bezier(.2, .7, .2, 1); + --duration: .2s; + } + + @media (pointer: coarse) { + :root { --target: 2.75rem; } + } +} + +/* ------------------------------------------------------------------ base */ +@layer base { + html { + scroll-behavior: smooth; + scrollbar-gutter: stable; + -webkit-text-size-adjust: 100%; + } + html:has(dialog[open]) { overflow: hidden; } /* no background scroll behind a modal */ + + body { + background: var(--paper); + color: var(--ink); + font-family: var(--font-sans); + line-height: 1.5; + } + + h1, h2, h3 { text-wrap: balance; } + + :focus-visible { + outline: 3px solid var(--focus); + outline-offset: 4px; + } + .hero :focus-visible { outline-color: var(--lime); } /* the green ring is only 2.6:1 on forest */ + + .eyebrow { + font-family: var(--font-mono); + font-size: var(--text-label); + font-weight: 700; + letter-spacing: var(--track-label); + line-height: 1.4; + } + .price { + font-family: var(--font-mono); + font-size: var(--text-small); + font-weight: 700; + font-variant-numeric: tabular-nums; + margin-top: var(--space-1); + } + .wordmark { + font-size: 2rem; + font-weight: 850; + letter-spacing: -.06em; + line-height: 1; + } + .wordmark span { color: var(--leaf); } +} + +/* ------------------------------------------------------------------ layout */ +@layer layout { + .notice { + padding: .62rem var(--space-4); + background: var(--lime); + text-align: center; + font-family: var(--font-mono); + font-size: clamp(.6rem, 2.7vw, var(--text-label)); /* scales down so it stays on one line */ + font-weight: 700; + letter-spacing: var(--track-label); + } + + .top, main, footer { width: var(--page); margin-inline: auto; } + + .top { + height: 78px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + } + .top nav { display: flex; gap: var(--space-6); font-size: var(--text-small); } + .top nav a:hover, footer a:hover { text-decoration: underline; text-underline-offset: 4px; } + /* Standalone links get a full-size hit area (inline links inside paragraphs are exempt). */ + .top nav a, .story > a, footer > a:last-child { + display: inline-flex; + align-items: center; + min-height: var(--target); + } + + /* hero */ + .hero { + display: grid; + grid-template-columns: 43% 57%; + min-height: 520px; + background: var(--forest); + color: var(--on-dark); + } + .hero-copy { + padding: clamp(2rem, 5.4vw, 6rem); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 1.3rem; + } + .hero .eyebrow { color: var(--lime); } + .eyebrow i { + display: inline-block; + width: .55rem; + height: .55rem; + margin-right: var(--space-2); + border-radius: 50%; + background: currentColor; + } + .hero h1 { + font-size: var(--text-h1); + line-height: .84; + letter-spacing: var(--track-display); + word-spacing: .06em; + } + .hero h1 span { color: var(--lime); } + .hero-copy > p:not(.eyebrow) { max-width: 29ch; color: var(--on-dark-soft); } + .hero-art { position: relative; min-height: 100%; overflow: hidden; } + .hero-art img { position: absolute; width: 100%; height: 100%; object-fit: cover; } + .image-label { + position: absolute; + right: var(--space-4); + bottom: var(--space-4); + padding: .55rem var(--space-3); + background: var(--forest-deep); + color: #fff; + font-family: var(--font-mono); + font-size: var(--text-label); + letter-spacing: .07em; + } + + /* shop */ + .shop { padding: var(--space-8) 0; } + .shop-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-5); + } + .shop h2, .story h2 { + margin-top: var(--space-2); + font-size: var(--text-h2); + line-height: .98; + letter-spacing: var(--track-display); + word-spacing: .06em; + } + .shop-heading > p { color: var(--muted); } + .controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) 0; + margin-bottom: var(--space-5); + border-block: 1px solid var(--line); + } + #filters { display: flex; flex-wrap: wrap; gap: var(--space-2); } + .controls label { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-small); + } + .grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-6) var(--space-4); + } + + /* story */ + .story { padding: clamp(2rem, 6vw, 6rem); background: var(--sage); } + .story h2 { max-width: 15ch; } + .story > p:not(.eyebrow) { max-width: 53ch; margin: var(--space-5) 0 var(--space-4); line-height: 1.65; } + .story a { text-decoration: underline; text-underline-offset: 4px; } + + /* footer */ + footer { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--space-4); + padding: 2.5rem 0; + font-size: var(--text-small); + } + footer p { text-align: center; } + footer > a:last-child { + justify-self: end; + text-decoration: underline; + text-underline-offset: 4px; + } + + @media (max-width: 760px) { + .top { height: 65px; } + .top nav { gap: var(--space-3); } + .hero { grid-template-columns: 1fr; min-height: 0; } + .hero-art { min-height: 310px; } + .hero h1 { font-size: clamp(4.2rem, 14vw, 6.5rem); } + .shop { padding: var(--space-7) 0; } + .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + footer { grid-template-columns: 1fr; justify-items: start; gap: var(--space-3); } + footer p { text-align: left; } + footer > a:last-child { justify-self: start; } + } + @media (max-width: 420px) { + .top nav a:last-child { display: none; } + .shop-heading > p { display: none; } + } + @media (max-width: 380px) { + .notice { padding-inline: var(--space-2); letter-spacing: .04em; } + } + @media (max-width: 340px) { + .grid { grid-template-columns: 1fr; } + } +} + +/* ------------------------------------------------------------------ components */ +@layer components { + /* pills: navigation and toggles */ + .bag, .close, #filters button { + min-height: var(--target); + padding: 0 var(--space-4); + border: 1px solid var(--control); + border-radius: var(--radius-pill); + background: transparent; + } + .bag { display: inline-flex; align-items: center; gap: var(--space-2); } + .bag span { + display: inline-grid; + place-items: center; + width: 1.4rem; + height: 1.4rem; + border-radius: 50%; + background: var(--forest); + color: #fff; + font-size: .75rem; + font-variant-numeric: tabular-nums; + } + .close { min-width: var(--target); padding: 0; font-size: 1.35rem; line-height: 1; } + #filters button { font-size: var(--text-small); } + #filters button[aria-pressed="true"] { + background: var(--forest); + border-color: var(--forest); + color: #fff; + } + + /* square: actions and inputs */ + .primary { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + min-height: var(--target); + padding: .9rem 1.15rem; + border: 0; + background: var(--lime); + color: var(--ink); + font-weight: 750; + } + .add, .qty button { + display: grid; + place-items: center; + min-width: var(--target); + min-height: var(--target); + padding: 0; + border: 1px solid var(--control); + background: transparent; + font-size: 1.3rem; + line-height: 1; + } + .qty button:disabled { opacity: .4; cursor: not-allowed; } + .controls select { + min-height: var(--target); + padding: 0 var(--space-3); + border: 1px solid var(--control); + border-radius: 0; + background: transparent; + } + @media (hover: hover) { + .primary:hover { background: var(--lime-strong); } + .add:hover, .qty button:not(:disabled):hover { background: var(--lime); } + #filters button[aria-pressed="false"]:hover, + .bag:hover, .close:hover { background: var(--sage); } + } + + /* product card */ + .product { min-width: 0; } + .product-image { display: block; width: 100%; padding: 0; border: 0; background: transparent; } + .product-image img { transition: filter var(--duration); } + @media (hover: hover) { + .product-image:hover img { filter: brightness(.94); } + } + .product-info { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + gap: var(--space-2); + padding: var(--space-3) 0; + } + .product h3 { font-size: var(--text-title); letter-spacing: -.03em; line-height: 1.2; } + .product .eyebrow { margin-bottom: var(--space-1); color: var(--muted); } + + /* dialogs */ + dialog { + width: 100%; + max-width: min(520px, calc(100% - 2rem)); + max-height: 90dvh; + padding: 1.3rem; + overflow: auto; + border: 1px solid var(--line); + background: var(--paper); + color: var(--ink); + } + dialog::backdrop { background: var(--scrim); } + .dialog-head, .subtotal { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-4); + } + #product-detail h2 { margin-top: var(--space-4); font-size: 1.6rem; letter-spacing: -.03em; line-height: 1.15; } + #product-detail > p { margin: var(--space-3) 0 var(--space-4); line-height: 1.5; } + + /* bag drawer */ + #cart { + height: 100dvh; + max-height: 100dvh; + max-width: min(470px, 100%); + margin: 0 0 0 auto; + border: 0; + box-shadow: var(--shadow-float); + } + .cart-row { + display: grid; + grid-template-columns: 75px 1fr auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--line); + } + .qty { + display: grid; + grid-template-columns: var(--target) 2ch var(--target); /* fixed columns: rows line up */ + align-items: center; + gap: var(--space-1); + text-align: center; + font-variant-numeric: tabular-nums; + } + .subtotal { margin-top: var(--space-4); } + .fine { margin-top: var(--space-3); font-size: var(--text-small); color: var(--muted); } + .empty { margin-bottom: var(--space-4); color: var(--muted); } + #order { padding: var(--space-4); background: var(--sage); line-height: 1.5; } + #order:focus-visible { outline-offset: 2px; } + + #toast { + position: fixed; + right: var(--space-4); + bottom: var(--space-4); + z-index: 3; + max-width: calc(100% - 2rem); + padding: var(--space-3) var(--space-4); + background: var(--forest); + color: var(--on-dark); + box-shadow: var(--shadow-float); + } + #toast:empty { display: none; } + .notice-js { padding: var(--space-4); background: var(--sage); } + + @media (max-width: 760px) { + #toast { left: var(--space-4); } + } +} + +/* ------------------------------------------------------------------ motion */ +@layer motion { + @media (prefers-reduced-motion: no-preference) { + dialog[open] { animation: rise var(--duration) var(--ease); } + #cart[open] { animation: slide-in .28s var(--ease); } + dialog[open]::backdrop { animation: fade var(--duration) ease-out; } + #toast:not(:empty) { animation: rise .24s var(--ease); } + } + @media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + * { transition: none !important; } + } + @keyframes rise { from { opacity: 0; translate: 0 .5rem; } } + @keyframes slide-in { from { translate: 100% 0; opacity: 1; } } + @keyframes fade { from { opacity: 0; } } +} + +/* ------------------------------------------------------------------ utilities */ +@layer utilities { + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + .skip-link { + position: absolute; + left: var(--space-4); + top: var(--space-2); + z-index: 10; + padding: var(--space-2) var(--space-4); + background: var(--forest); + color: var(--on-dark); + translate: 0 -200%; + } + .skip-link:focus { translate: 0 0; } + [hidden] { display: none !important; } +}