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; } +}