# Afield: everyday outdoor apparel with a complete local shopping flow

Build Afield as an original outdoor apparel e-commerce concept. It must be distinct from a travel landing page, furniture shop, art catalog, or support-software design. Deliver a full editorial commerce homepage, a filterable collection page, three substantial product-detail pages, a useful fit-and-care guide, and a variant-aware local bag with order-draft preparation. The product collection is deliberately small, but each piece needs complete photography, description, material information, size choice, fit context, care notes, and a real path into the bag.

The collection contains the Field Overshirt, Shore Crew, and Wayfarer Pack. All products, prices, materials, and measurements are original concept specifications, not an actual manufacturing catalog. The store does not accept payment, reserve stock, calculate shipping or tax, or fulfill orders. A visitor can make a local selection and download a clear order draft. Do not add fake checkout, payment badges, shipping promises, stock scarcity, certifications, customer reviews, or registration claims to make it look real. The interface should feel complete because the shopping logic and content agree.

## Research and structural inspiration

Inspect https://finisterre.com/ and the region-resolved storefront https://finisterre.com/en-us. A directly inspected product reference is https://finisterre.com/en-us/products/mens-organic-tonkin-work-smock-mahogany-brown. The source homepage uses full-bleed coastal campaign photography and video, product/category navigation, collection merchandising, product rails, a substantial brand/material story, care/longevity information, editorial content, and repeated commerce access. Its meaning comes from clothing and product use, not from selling a destination.

The observed product page places large lifestyle and studio images in a two-column gallery beside a clear purchase-information rail. Title, material description, price, color, size guide, size control, and Add to basket are closely grouped. Description, construction features, care, material, origin, and related products continue below. The useful lesson is proximity: the shopper can understand the garment and its fit without leaving the buying decision behind. In the live browser, an initial newsletter modal was dismissed through its visible close control before visual inspection; no signup or purchase was performed.

Translate the product-first editorial rhythm, substantial imagery, size clarity, and material/care depth. Do not copy Finisterre’s logo, slogans, product names, prices, garment design, photographs, customer content, certificates, sourcing claims, or code. Afield is its own original small collection, with a different wordmark, palette, garments, copy, and local interaction model. The campaign is an imagined coastal scene with a fictional adult walker. It is not represented as a real destination, ambassador, or documentary expedition.

## Identity and readable visual system

Use locally bundled Geist Variable throughout with font-display swap and its SIL Open Font License. The wordmark is AFIELD in uppercase, weight650, tight tracking around minus2px, approximately34px at desktop. Do not use a registered-trademark symbol. The rest of the typography is clean, calm, and utilitarian: large low-contrast-weight display text, clear product names, readable body text, and restrained uppercase context labels. Avoid the serif/portrait language of unrelated support concepts and avoid tiny technical labels.

Palette: ink #192e38, muted #58676b, paper #f7f6f1, rules #c9cfca, navy #203743, sand #e4e0d2, and olive #dce0cf. Product photography shares a warm pale-stone background, while editorial imagery carries gray-blue sea and olive clothing. Main body text is16–18px. Actual controls and navigation are at least14px, secondary labels at least12px, and size choices become16px on narrow phones. Use hierarchy through spacing and weight rather than pale unreadable text.

The main container is1440px maximum with35px inner gutters. A30px olive utility strip introduces the collection without claiming shipping or discounts. The sticky82px header has three balanced columns: Shop and Fit & care at the left, the wordmark centered, and a real bag link with total-unit count at the right. The background is nearly opaque paper with a subtle blur and a fine rule. Current-page navigation uses aria-current. On narrow screens, preserve Shop, centered brand, and bag access; the fit guide remains reachable from products and footer.

Primary buttons are square, navy, white text, at least50px high, and padded13px by22px with an18–20px icon gap. Hero buttons use pale sand with dark ink. Text links have a deliberate underline or bottom rule. Visible keyboard focus is a three-pixel burnt-orange outline with five-pixel offset. Use one licensed Lucide icon family through Afield’s own local helper. Do not import another demo’s structure. No favicon links or platform/assistant branding should appear in public pages or credit files.

## Editorial commerce homepage

The homepage begins with a full-width original campaign photograph at a minimum730px height on desktop. The image is an actual img with explicit1600×900 dimensions, covering the region. A dark left-weighted overlay protects white text without obscuring the product use entirely. The adult walker remains toward the right; the left holds the short collection label, a large three-line heading about outside being a good place to start, a concise two-line supporting phrase, and a real collection action. The heading scales between60px and94px, with a line height near.98 and tight tracking. The caption says it is an original imagined campaign, not a claim about a real location.

Follow with a generous collection introduction and three product cards. The introduction pairs a48px heading at the left with a17px explanation at the right. Product images use a4:5 ratio, consistent warm-stone studio backgrounds, and the complete object. Each card has a real product link, category, name, sample price, color swatch with a textual color name, and size range. The product name is21px or larger and the price16px or larger. Desktop hover reveals an Explore strip and a subtle image scale; keyboard focus also reveals the action. Phones show the action without requiring hover.

Next, feature the Field Overshirt in a full-width split editorial section on olive. The left uses a close crop of the original front product image, clearly as detail rather than pretending it is a second angle. The right explains how the relaxed layer belongs in everyday routines, provides a real product action, and lists three relevant construction facts. It must not make waterproof, protective, sustainability, factory, or durability guarantees that this concept cannot verify. The garment is an outer layer, not a substitute for weather-rated protective equipment.

The layering section contains three connected category panels: outer layer, knit, and carry. These are meaningful collection filters, not decorative cards. Each has a category label, a short original title, a concise use explanation, and a whole-panel link to the matching collection query. Use olive, sand, and blue-gray surfaces with fine separating rules. Avoid a repetitive grid of generic benefit boxes. The panels should help a shopper decide which kind of piece to inspect.

A fit-and-care editorial section follows. It has a large heading, useful guidance, a real fit-guide action, and a numbered care checklist. Explain measuring a garment someone already likes, allowing room for layers, and following product-specific care. Close with another crop of the original coast campaign and a simple collection action. The full page should lead repeatedly back to products while remaining editorial, not turn into a travel itinerary or booking site.

## Original product collection

Field Overshirt: olive heavyweight cotton canvas, sample price14800 cents, sizes S/M/L/XL. It has a full button front, two chest pockets, straight hem, and button cuffs. The fit is relaxed through chest and shoulders with room for a light layer. The supplied front product image must show the whole garment. Original flat measurements in centimeters are S55/70/61, M58/72/62, L61/74/63, XL64/76/64 for chest width, length, sleeve. These are fictional garment specifications, not personal body measurements.

Shore Crew: chalk/ecru textured wool knit, sample price9600 cents, sizes S/M/L/XL. It has a crew neck, relaxed shoulder, textured construction, and ribbed neck/cuffs/hem. Its fit is an easy regular shape. Original flat measurements are S53/66/60, M56/68/61, L59/70/62, XL62/72/63. Care emphasizes airing, cool hand washing, pressing water out gently, and drying flat. Do not imply the prototype was laboratory tested or manufactured to a specific certification.

Wayfarer Pack: ember/burnt-orange canvas rolltop bag, sample price8600 cents, One size. It has adjustable straps, an outer pocket, rolltop opening, and webbing details. Original rolled dimensions are44cm high,28cm wide,14cm deep. Do not invent a precise liter capacity from those dimensions or claim waterproofing. The product page clearly names One size, which may be preselected because it is the only variant. Clothing sizes must require an explicit choice.

All product records live in one source module, including IDs, names, category, price in integer cents, color, swatch, image filename, valid sizes, introduction, complete description, material, fit, care, feature list, measurements, and use statement. Builder and browser handler use this same data. Never let displayed card price, product price, bag line price, and subtotal diverge through duplicated price constants. The exact source records are appended to the recreation prompt with the interaction code.

## Collection search, filters, sorting, and empty state

shop.html has a full heading and introduction, then a functional control row. Search checks product name, material, color, and category case-insensitively. Category options are All pieces, Outer layers, Knitwear, and Carry. Size options are All sizes, S, M, L, XL, and One size. The category and size filters intersect the search. For example, Carry plus S must produce no results rather than silently displaying the bag. Sorting supports featured source order, ascending price, descending price, and name.

The result count is actual and announced politely. Clear filters resets search, category, size, and sort. A no-results region names the mismatch and offers Show all pieces, using the same reset behavior. Recognized category query parameters preselect the corresponding category; unknown values use the default rather than an invalid empty control. All cards open complete product pages. There is no fake quick-add that bypasses a clothing size decision.

Keep the filter labels visible. Native selects use14px text and at least46px height. Search has an accessible name independent of its placeholder. At tablet widths controls wrap without becoming tiny; at phone width search spans the row, category and size share a row, and sort spans the next row. The product grid becomes one column on narrow phones to preserve garment detail and readable metadata. Do not squeeze three product thumbnails into a390px viewport.

## Product-detail pages and variant selection

Generate product-field-overshirt.html, product-shore-crew.html, and product-wayfarer-pack.html as full pages. Each begins with a breadcrumb, then a large product photograph and information rail. Desktop columns are approximately1.2fr and1fr, with70px gap. The image is the whole original product view; clicking View larger opens a native dialog with the same high-resolution image and a clear44px close control. Do not invent alternate images or mislabeled gallery views. Escape closes the dialog through native behavior.

The information rail is sticky near110px on wide screens and becomes normal flow when stacked. It includes category, a48px product title,18px introduction,24px price, explicit USD sample-price label, color swatch plus name, size guide link, size choices, Add to bag, status, and a useful View your bag link after successful addition. The fit link opens the guide with that exact product preselected. The color is fixed for each product; do not offer an unpictured color variation with a misleading unchanged image.

Clothing size controls are native radio inputs inside a fieldset with a legend. No size is selected initially. Each label presents a clear bordered target around57×52px, with selected navy fill and a visible focus ring. One-size carry may be checked initially. Add to bag is a real form action with native required validation and a second data-model validation. Invalid or missing size must not alter storage or the bag badge. Successful addition names the product and selected size in the status and exposes the bag link.

Below the action, state the local commerce boundary. Product accordions include The piece, Material & fit, Care notes, and About the order draft. The first opens initially with a complete description and features. Other sections are native details with meaningful text and links. They must not be empty headings. Follow the primary product section with a substantial use statement, fit link, and related-product cards. The page remains a useful shopping destination beyond the first viewport.

## Fit guide and care content

fit.html is a complete guide, not only a modal table. It begins with a large introduction and a two-column working region. The left explains measuring a comparable garment flat and distinguishing garment dimensions from body dimensions. It explicitly says the concept dimensions do not guarantee personal fit. A ruler callout explains chest width measured armpit-to-armpit and that doubling gives an approximate garment circumference.

The right has a product selector and Centimeters/Inches buttons with aria-pressed. Selecting a product changes the title, fit description, measurement table, and View the piece link. For clothing, show S–XL rows and chest width, length, and sleeve columns. For the bag, show height, width, and depth. Inches are calculated from centimeters by division by2.54, rounded to one decimal. Do not mix the selected unit in table labels or leave old product dimensions visible after switching. The guide respects known product query parameters.

Below the tool, three readable principles explain chest, length, and layering. The care section then gives a distinct article for each original material and product. All instructions match the product records. The overshirt uses gentle cool cleaning and air drying; the knit uses gentle wool care and flat drying; the pack uses spot cleaning and open drying. These are concept care specifications, not assertions about an actual certified fabric. Keep care reading text17px and table body at least15–16px on phones.

## Variant-aware local bag and exact money handling

Use afield-bag-v1 as the sole bag storage key. A line is identified by product ID plus selected size; the product’s fixed color is derived from the catalog. Adding the same product and size increments that line. Adding the same garment in a different size creates a separate line. The bag badge is total units, not number of distinct lines. Quantities are integers from1 through9. Repeated addition at9 must report the limit without altering the bag.

Store only product, size, and quantity. Recover only a bounded array of valid catalog IDs, valid sizes, integer quantities, and unique variant keys. There are at most nine possible distinct variants in this collection. Malformed local storage should safely produce an empty bag rather than a crash or arbitrary price. Money remains integer cents for every calculation. afSubtotal multiplies each catalog price by quantity and sums the results. Format USD only at the display boundary.

bag.html has a complete empty state with a collection action. A filled bag shows each variant’s image, product name/link, category, color, size, unit price, line total, decrement/increment controls, and remove action. Quantity buttons clearly name the product and size in their accessible labels. Disable decrement at1 and increment at9. Removing a line removes only that variant. After changing quantity or removing, recalculate badge and subtotal from current lines. If the last line is removed, return to the empty state and hide the draft controls.

Persist updates before claiming success. If a storage write fails, retain the existing saved bag and explain that it was not changed. Product additions should likewise not pretend to create a persistent bag when storage is unavailable. Do not clear unrelated keys. No network requests, account, payment information, personal delivery details, or credential collection belong in this local concept.

## Order draft, preview, and download

The bag summary contains the actual subtotal, a statement that prices are sample USD, and a clear note that shipping and taxes are not quoted. It provides a required draft name, limited to80 characters, and an optional note limited to1200. No address, card, or email is requested. Preparing the draft validates a non-whitespace name and a nonempty bag, then reveals a readable text preview. It must say this is not an order confirmation, stock reservation, purchase, or delivery arrangement.

The preview includes every selected product, fixed color, size, quantity, line total, subtotal, unquoted shipping/tax, uncollected payment, and the optional note. Use textContent and wrapped text rather than inserting user text as HTML. If bag quantities change after preparation, update the existing preview. If the draft name or note changes, update its text too. Download must serialize current state at the moment of action, not an old snapshot hidden in a closure.

The file is afield-order-draft.md with text/markdown MIME type, created through a Blob and a temporary object URL. Revoke the URL after a short delay. The status says the draft was prepared for download and that no order or payment was created. Do not display a fake order number, dispatch date, successful payment, or confirmation email. Finish the bag with fit and care links so uncertainty can be resolved before the shopper keeps the draft.

## Responsive behavior, motion, and accessibility

At1100px tighten gaps to40–60px, reduce the hero display to78px, and retain a balanced product-information rail. At800px reduce the header to74px and use28px gutters. Product details stack and the information rail stops being sticky. The garment image remains contained rather than cropped. The product grid uses two columns until500px. Fit, care story, and bag sections stack; the bag summary becomes normal flow. Large prose remains readable instead of being scaled to a desktop mockup.

At500px use20px gutters and a68px header. The hero is roughly640px tall with a58px heading,17px support copy, and a crop that keeps the walker visible. The overlay becomes uniform enough to protect the text. Collection and related grids use one column. Product titles are39px, size choices16px with54px target height, and Add to bag remains full width. The fit control row stacks; measurement tables use comfortable14–15px labels/cells. Bag rows become image plus details, with line price below the details rather than squeezed offscreen. No page should horizontally overflow at390px.

Motion is restrained commerce feedback. Product images scale to1.035 over.5 seconds on desktop hover. The Explore strip fades and moves five pixels into place, also on keyboard focus. Product grids enter once using IntersectionObserver threshold.15, a20px translation, and.55-second animation with .1/.2-second card delays. Category panels may enter over.6 seconds. These effects do not delay purchasing controls, change prices, or animate fake inventory. Reduced motion removes animation, transitions, smooth scrolling, and hover scaling. Prepared-draft scrolling respects the same preference.

Maintain contrast for white text on campaign images with a real dark overlay. Keep secondary text at least12px and controls14px. Use actual field labels, native radios/selects/details/dialogs, visible focus, descriptive image alt text, live status outputs, and predictable link destinations. Whitespace must remain around hard line breaks when a breakpoint hides them, so mobile sentences never join. Verify long names, status messages, one-size labels, and quantity controls at narrow width.

## Original imagery, build contract, and verification

Bundle four original images: coast.webp for the imagined adult coastal campaign, overshirt.webp for the olive canvas front view, crew.webp for the chalk wool crewneck, and daypack.webp for the ember rolltop. Product images share a warm pale-stone studio and a4:5 ratio; the campaign is16:9. Include hero.webp as a local campaign preview, exact prompt provenance, and public generic generation credits. Private source paths may remain in scripts/afield/asset-sources.json but must not expose local machine paths in public provenance. Keep Geist and Lucide license files. No copied brand assets or unsupported sourcing claims.

Export buildAfield() returning {prompt}, writing only the owned Afield pages and matching recreation prompts. Keep records, helpers, markup, styles, and browser interactions within scripts/afield. Generate public/demos/afield/PROMPT.md and public/prompts/afield.md with this detailed recipe and actual code under the exact heading Reference interaction handler. The assembled prompt must exceed3000 substantive words and preserve real variant and subtotal behavior.

Run scoped lint, syntax, and unit tests for valid/invalid lines, separate sizes, same-size increments, quantity limits, and integer-cents totals. In the browser verify missing-size prevention, adding two sizes of one garment, repeated addition, bag quantity changes/removal, persistent reload, matching unit conversion, collection filter intersection, sort order, no-result recovery, product zoom dialog, order preview, and current-state download. Inspect desktop homepage and product layout, then every route at390px. Confirm one h1, working local links/assets, accessible controls, readable text, and no horizontal document overflow. Restore QA-only bag contents through visible controls. Use Lighthouse as supporting evidence, not as a substitute for real visual and workflow review.

## Responsive campaign delivery

Include coast-800.webp as a compact800px-wide rendition of the same original campaign image. Both campaign placements use an800w/1600w srcset and sizes100vw, so phone browsers can select a smaller file without changing composition. The first campaign image has fetchpriority=high; the lower repeated campaign remains lazy-loaded. Explicit image dimensions and CSS coverage preserve layout stability. The large1600px original rendition remains available for desktop and high-density views.


## Reference interaction handler

```javascript
const afProducts=[{"id":"field-overshirt","name":"Field Overshirt","category":"Outer layers","price":14800,"color":"Olive","swatch":"#62674c","image":"overshirt","sizes":["S","M","L","XL"],"intro":"A dependable outer layer for the space between a plan and a detour.","description":"A relaxed canvas overshirt with a clean collar, two useful chest pockets, and room for a light layer underneath. The straightforward shape belongs as much on a daily walk as it does beside a workbench.","material":"Heavyweight cotton canvas","fit":"Relaxed through the chest and shoulders. Compare the flat garment measurements with a layer you already like.","care":"Brush away dry surface dirt. For this concept garment, use a cool gentle wash and reshape while damp. Let it air dry; avoid a hot tumble dry.","details":["Full button front","Two chest pockets","Straight, easy-layering hem","Adjustable button cuffs"],"measurements":[["S",55,70,61],["M",58,72,62],["L",61,74,63],["XL",64,76,64]],"use":"Over a tee when the air turns cooler. Under a shell when the weather calls for one."},{"id":"shore-crew","name":"Shore Crew","category":"Knitwear","price":9600,"color":"Chalk","swatch":"#e1d9c9","image":"crew","sizes":["S","M","L","XL"],"intro":"The layer you reach for when the light stays out a little longer.","description":"A simple textured crewneck with an easy shoulder and a generous rib at the neck, cuffs, and hem. Its quiet shape sits comfortably alongside the rest of the collection.","material":"Textured wool knit","fit":"Easy, regular fit. Choose by your preferred sweater width rather than a tight body measurement.","care":"Air between wears and spot clean where possible. For this concept knit, hand wash cool with wool detergent, gently press out water, and dry flat. Do not hang while wet.","details":["Classic crew neck","Textured knit construction","Ribbed cuffs and hem","Room for a light base layer"],"measurements":[["S",53,66,60],["M",56,68,61],["L",59,70,62],["XL",62,72,63]],"use":"A comfortable midlayer for slow mornings, late trains, and the walk home."},{"id":"wayfarer-pack","name":"Wayfarer Pack","category":"Carry","price":8600,"color":"Ember","swatch":"#ac552e","image":"daypack","sizes":["One size"],"intro":"A little room for whatever the day turns into.","description":"An uncomplicated rolltop daypack with a useful outer pocket, adjustable shoulder straps, and a broad opening. Pack the essentials, leave a little room, and head out.","material":"Cotton canvas with webbing straps","fit":"One size with adjustable straps. Concept dimensions: 44 cm high, 28 cm wide, 14 cm deep when rolled.","care":"Empty the bag and brush away loose dirt. Spot clean with cool water and allow to dry open. Avoid machine washing or soaking the hardware.","details":["Rolltop opening","Adjustable shoulder straps","Outer pocket","Webbing compression details"],"measurements":[],"use":"For the extra layer, a notebook, and the small things that make a day your own."}];
function afMoney(cents) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    maximumFractionDigits: 0,
  }).format(cents / 100);
}
function afValidLine(line) {
  const p = afProducts.find((p) => p.id === line?.product);
  return !!(
    p &&
    p.sizes.includes(line.size) &&
    Number.isInteger(line.quantity) &&
    line.quantity >= 1 &&
    line.quantity <= 9
  );
}
function afSubtotal(lines) {
  return lines.reduce(
    (sum, line) =>
      sum + afProducts.find((p) => p.id === line.product).price * line.quantity,
    0,
  );
}
function afAddLine(lines, product, size) {
  const p = afProducts.find((p) => p.id === product);
  if (!p || !p.sizes.includes(size))
    return {
      ok: false,
      lines,
      message: 'Choose a valid size before adding to your bag.',
    };
  const existing = lines.find((l) => l.product === product && l.size === size);
  if (existing?.quantity >= 9)
    return {
      ok: false,
      lines,
      message: 'This size already has the maximum quantity of 9.',
    };
  return {
    ok: true,
    lines: existing
      ? lines.map((l) =>
          l === existing ? { ...l, quantity: l.quantity + 1 } : l,
        )
      : [...lines, { product, size, quantity: 1 }],
    message: p.name + ' / ' + size + ' added to your bag.',
  };
}
const afReduced = matchMedia('(prefers-reduced-motion: reduce)');
const afStore = {
  read() {
    try {
      return JSON.parse(localStorage.getItem('afield-bag-v1') || '[]');
    } catch {
      return [];
    }
  },
  write(lines) {
    try {
      localStorage.setItem('afield-bag-v1', JSON.stringify(lines));
      return true;
    } catch {
      return false;
    }
  },
};
const afEsc = (text) =>
  String(text).replace(
    /[&<>"']/g,
    (c) =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
        c
      ],
  );
const afRaw = afStore.read();
let afBagLines =
  Array.isArray(afRaw) &&
  afRaw.length <= 9 &&
  afRaw.every(afValidLine) &&
  new Set(afRaw.map((l) => l.product + '::' + l.size)).size === afRaw.length
    ? afRaw
    : [];
function afUpdateBadge() {
  document
    .querySelectorAll('[data-bag-count]')
    .forEach(
      (el) => (el.textContent = afBagLines.reduce((n, l) => n + l.quantity, 0)),
    );
}
afUpdateBadge();
const afProductNode = document.querySelector('[data-product]');
if (afProductNode) {
  const product = afProducts.find(
    (p) => p.id === afProductNode.dataset.product,
  );
  document
    .getElementById('af-product-form')
    .addEventListener('submit', (event) => {
      event.preventDefault();
      const selected = document.querySelector('input[name="size"]:checked');
      const result = afAddLine(afBagLines, product.id, selected?.value);
      if (!result.ok) {
        document.getElementById('af-add-status').textContent = result.message;
        return;
      }
      if (!afStore.write(result.lines)) {
        document.getElementById('af-add-status').textContent =
          'Browser storage is unavailable. This selection could not be added to a persistent bag.';
        return;
      }
      afBagLines = result.lines;
      afUpdateBadge();
      document.getElementById('af-add-status').textContent = result.message;
      document.getElementById('af-view-bag').hidden = false;
    });
  const dialog = document.getElementById('af-image-dialog');
  document
    .getElementById('af-zoom')
    .addEventListener('click', () => dialog.showModal());
  document
    .getElementById('af-close-image')
    .addEventListener('click', () => dialog.close());
}
function afRenderProductCard(p) {
  return `<article class="product-card"><a href="product-${p.id}.html" class="product-image"><img src="assets/${p.image}.webp" width="1000" height="1250" loading="lazy" alt="${afEsc(p.color + ' ' + p.name)}, original product concept"><span>Explore ↗</span></a><div class="card-meta"><span>${p.category}</span><div><h3><a href="product-${p.id}.html">${p.name}</a></h3><b>${afMoney(p.price)}</b></div><p><i style="background:${p.swatch}"></i>${p.color} · ${p.sizes.length === 1 ? 'One size' : 'S – XL'}</p></div></article>`;
}
function afApplyFilters() {
  const q = document.getElementById('af-search').value.trim().toLowerCase();
  const category = document.getElementById('af-category').value;
  const size = document.getElementById('af-size-filter').value;
  const sort = document.getElementById('af-sort').value;
  const rows = afProducts.filter(
    (p) =>
      (category === 'All pieces' || p.category === category) &&
      (size === 'All sizes' || p.sizes.includes(size)) &&
      [p.name, p.material, p.color, p.category]
        .join(' ')
        .toLowerCase()
        .includes(q),
  );
  if (sort === 'low') rows.sort((a, b) => a.price - b.price);
  if (sort === 'high') rows.sort((a, b) => b.price - a.price);
  if (sort === 'name') rows.sort((a, b) => a.name.localeCompare(b.name));
  document.getElementById('af-product-results').innerHTML = rows
    .map(afRenderProductCard)
    .join('');
  document.getElementById('af-no-results').hidden = rows.length > 0;
  document.getElementById('af-results-count').textContent =
    rows.length + (rows.length === 1 ? ' piece' : ' pieces');
}
function afClearFilters() {
  document.getElementById('af-search').value = '';
  document.getElementById('af-category').value = 'All pieces';
  document.getElementById('af-size-filter').value = 'All sizes';
  document.getElementById('af-sort').value = 'featured';
  afApplyFilters();
}
if (document.getElementById('af-search')) {
  const category = new URLSearchParams(location.search).get('category');
  if (afProducts.some((p) => p.category === category))
    document.getElementById('af-category').value = category;
  document
    .getElementById('af-search')
    .addEventListener('input', afApplyFilters);
  for (const id of ['af-category', 'af-size-filter', 'af-sort'])
    document.getElementById(id).addEventListener('change', afApplyFilters);
  document
    .getElementById('af-clear-filters')
    .addEventListener('click', afClearFilters);
  document
    .getElementById('af-reset-results')
    .addEventListener('click', afClearFilters);
  afApplyFilters();
}
let afUnit = 'cm';
function afMeasurement(value) {
  return afUnit === 'cm' ? String(value) : (value / 2.54).toFixed(1);
}
function afRenderFit() {
  const product = afProducts.find(
    (p) => p.id === document.getElementById('af-fit-product').value,
  );
  document.getElementById('af-fit-title').textContent = product.name;
  document.getElementById('af-fit-description').textContent = product.fit;
  document.getElementById('af-back-product').href =
    'product-' + product.id + '.html';
  const table = product.measurements.length
    ? `<table><caption>Flat garment measurements / ${afUnit}</caption><thead><tr><th>Size</th><th>Chest width</th><th>Length</th><th>Sleeve</th></tr></thead><tbody>${product.measurements
        .map(
          (row) =>
            `<tr><th scope="row">${row[0]}</th>${row
              .slice(1)
              .map((v) => `<td>${afMeasurement(v)}</td>`)
              .join('')}</tr>`,
        )
        .join('')}</tbody></table>`
    : `<table><caption>Rolled bag dimensions / ${afUnit}</caption><thead><tr><th>Height</th><th>Width</th><th>Depth</th></tr></thead><tbody><tr>${[44, 28, 14].map((v) => `<td>${afMeasurement(v)}</td>`).join('')}</tr></tbody></table>`;
  document.getElementById('af-measurements').innerHTML = table;
}
if (document.getElementById('af-fit-product')) {
  const requested = new URLSearchParams(location.search).get('product');
  if (afProducts.some((p) => p.id === requested))
    document.getElementById('af-fit-product').value = requested;
  document
    .getElementById('af-fit-product')
    .addEventListener('change', afRenderFit);
  document.querySelectorAll('[data-unit]').forEach((button) =>
    button.addEventListener('click', () => {
      afUnit = button.dataset.unit;
      document
        .querySelectorAll('[data-unit]')
        .forEach((b) => b.setAttribute('aria-pressed', String(b === button)));
      afRenderFit();
    }),
  );
  afRenderFit();
}
let afPrepared = false;
function afDraftText() {
  const name =
    document.getElementById('af-order-name').value.trim() ||
    'Afield order draft';
  const note = document.getElementById('af-order-note').value.trim();
  return (
    '# ' +
    name +
    '\n\nAfield local order draft. No purchase has been placed.\n\n' +
    afBagLines
      .map((line) => {
        const p = afProducts.find((p) => p.id === line.product);
        return (
          '- ' +
          p.name +
          ' / ' +
          p.color +
          ' / ' +
          line.size +
          ' × ' +
          line.quantity +
          ' — ' +
          afMoney(p.price * line.quantity)
        );
      })
      .join('\n') +
    '\n\nSubtotal: ' +
    afMoney(afSubtotal(afBagLines)) +
    ' USD\nShipping and taxes: not quoted\nPayment: not collected\n\n## Notes\n\n' +
    (note || 'No notes added.') +
    '\n\nOriginal fictional product collection. This draft does not reserve stock or arrange fulfillment.\n'
  );
}
function afRenderBag() {
  const empty = !afBagLines.length;
  document.getElementById('af-empty-bag').hidden = !empty;
  document.getElementById('af-filled-bag').hidden = empty;
  document.getElementById('af-order-preview').hidden = empty || !afPrepared;
  if (empty) {
    afPrepared = false;
    return;
  }
  document.getElementById('af-bag-lines').innerHTML = afBagLines
    .map((line, index) => {
      const p = afProducts.find((p) => p.id === line.product);
      return `<article class="bag-row"><a href="product-${p.id}.html"><img src="assets/${p.image}.webp" width="1000" height="1250" alt="${afEsc(p.name)}"></a><div><span>${p.category}</span><h2><a href="product-${p.id}.html">${p.name}</a></h2><p>${p.color} / ${afEsc(line.size)}</p><div class="quantity-controls"><button data-quantity="${index}" data-change="-1" ${line.quantity <= 1 ? 'disabled' : ''} aria-label="Decrease ${afEsc(p.name + ' ' + line.size)} quantity">−</button><output aria-label="Quantity">${line.quantity}</output><button data-quantity="${index}" data-change="1" ${line.quantity >= 9 ? 'disabled' : ''} aria-label="Increase ${afEsc(p.name + ' ' + line.size)} quantity">+</button></div><button class="remove-line" data-remove="${index}">Remove ${afEsc(line.size)}</button></div><div class="line-price"><strong>${afMoney(p.price * line.quantity)}</strong><span>${afMoney(p.price)} each</span></div></article>`;
    })
    .join('');
  document.getElementById('af-subtotal').textContent = afMoney(
    afSubtotal(afBagLines),
  );
  if (afPrepared)
    document.getElementById('af-order-text').textContent = afDraftText();
}
function afCommitBag(next) {
  if (!afStore.write(next)) {
    document.getElementById('af-bag-status').textContent =
      'Storage unavailable. Your saved bag was not changed.';
    return;
  }
  afBagLines = next;
  afUpdateBadge();
  afRenderBag();
  document.getElementById('af-bag-status').textContent =
    'Bag updated in this browser.';
}
if (document.getElementById('af-bag-lines')) {
  afRenderBag();
  document.getElementById('af-bag-lines').addEventListener('click', (event) => {
    const quantity = event.target.closest('[data-quantity]');
    const remove = event.target.closest('[data-remove]');
    if (quantity) {
      const index = Number(quantity.dataset.quantity);
      const next = afBagLines.map((line, n) =>
        n === index
          ? {
              ...line,
              quantity: Math.min(
                9,
                Math.max(1, line.quantity + Number(quantity.dataset.change)),
              ),
            }
          : line,
      );
      afCommitBag(next);
    }
    if (remove)
      afCommitBag(
        afBagLines.filter(
          (_, index) => index !== Number(remove.dataset.remove),
        ),
      );
  });
  document
    .getElementById('af-order-form')
    .addEventListener('submit', (event) => {
      event.preventDefault();
      if (!afBagLines.length) return;
      const input = document.getElementById('af-order-name');
      input.setCustomValidity(input.value.trim() ? '' : 'Name this draft.');
      if (!input.reportValidity()) return;
      afPrepared = true;
      afRenderBag();
      document.getElementById('af-order-status').textContent =
        'Order draft prepared locally. No order was placed.';
      document
        .getElementById('af-order-preview')
        .scrollIntoView({
          behavior: afReduced.matches ? 'auto' : 'smooth',
          block: 'start',
        });
    });
  document
    .getElementById('af-order-name')
    .addEventListener('input', (event) => {
      event.target.setCustomValidity('');
      if (afPrepared)
        document.getElementById('af-order-text').textContent = afDraftText();
    });
  document.getElementById('af-order-note').addEventListener('input', () => {
    if (afPrepared)
      document.getElementById('af-order-text').textContent = afDraftText();
  });
  document.getElementById('af-download-order').addEventListener('click', () => {
    if (!afBagLines.length) return;
    const url = URL.createObjectURL(
      new Blob([afDraftText()], { type: 'text/markdown;charset=utf-8' }),
    );
    const a = document.createElement('a');
    a.href = url;
    a.download = 'afield-order-draft.md';
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
    document.getElementById('af-order-status').textContent =
      'Draft prepared for download. No payment or order was created.';
  });
}
if ('IntersectionObserver' in window) {
  const afObserver = new IntersectionObserver(
    (entries) =>
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.classList.add('entered');
          afObserver.unobserve(entry.target);
        }
      }),
    { threshold: 0.15 },
  );
  document
    .querySelectorAll('.product-grid,.layering-grid,.field-story')
    .forEach((el) => afObserver.observe(el));
}

```
