diff --git a/README.md b/README.md new file mode 100644 index 0000000..b86e99e --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Fieldwork Supply + +A fictional outdoor-gear storefront, built as a portfolio demo. Static site, no build step, no +dependencies: plain HTML, CSS and ES modules, served by Caddy in Docker. + +## Layout + +``` +dist/ everything that ships (this folder is copied into the image) + index.html semantic markup, skip link, landmarks + style.css design tokens + cascade layers (see the header comment) + js/ + catalog.js product data; pure filter / sort / URL-state functions + cart.js pure bag logic (cents, limits, sanitising) + storage load/save + format.js money and plural helpers (integer cents, cached Intl formatters) + dom.js html`` template tag that escapes every interpolation + main.js wires the modules to the page; one delegated click handler +tests/ browser test suite (not shipped) +Dockerfile, Caddyfile, compose.yaml +``` + +## Run + +```sh +docker compose up -d --build # http://127.0.0.1:8088, see DOCKER.md +``` + +or, for quick edits, serve `dist/` with anything static: `python3 -m http.server -d dist`. + +## Test + +```sh +tests/run.sh # headless Chrome; needs python3 and Google Chrome +``` + +Or open `tests/` through any static server to see the results in a browser. There is nothing to +install. The suite has unit tests for the pure modules and integration tests that load the real +page in an iframe and use it like a visitor would: filtering, sorting, shareable URLs, the bag, +quantity limits, cross-tab sync, checkout, dialogs, and accessibility and overflow checks. + +## Design notes + +- **State and URL.** The category and sort live in the URL (`?category=Camp&sort=low`), so views + are shareable and survive a reload. Invalid values fall back to defaults. +- **Money is integer cents** everywhere, formatted once at the edge. +- **The bag** is `{ productId: quantity }` in `localStorage` (key unchanged from v1, so existing + bags carry over). Loading sanitises it, quantities are capped at 9, and it syncs across tabs. +- **No injection.** Every interpolated value in a template is escaped; `mount()` refuses plain + strings. Combined with the Content-Security-Policy in the Caddyfile (no inline script or style), + injected markup can neither render nor run. +- **Accessibility.** Skip link, landmarks, labelled dialogs, focus kept on the control you used, + announcements for bag changes, focus rings with at least 3:1 contrast on every surface, control + borders at 3:1, and no touch target under 24px. +- **Styling.** One token set (colour, type scale, space, shape, motion) in cascade layers. Pills + are for navigation and toggles, square corners for actions and inputs. + +## Known limits + +- Products are rendered by JavaScript; without it the page shows a notice instead of the shop. +- Assets are not fingerprinted, so they are cached for only five minutes. +- Mouse-sized controls are 40px; they grow to 44px on touch screens. diff --git a/tests/app.test.js b/tests/app.test.js new file mode 100644 index 0000000..3d9fca8 --- /dev/null +++ b/tests/app.test.js @@ -0,0 +1,237 @@ +/** + * Integration tests: load the real page in a same-origin iframe and drive it like a user. + * Each test gets a fresh page and an empty bag. + */ +import { STORAGE_KEY } from "../dist/js/cart.js"; +import { assert, equal, sleep, test } from "./harness.js"; + +async function withApp(fn, { search = "", stored, width = 1200 } = {}) { + localStorage.removeItem(STORAGE_KEY); + if (stored !== undefined) { + localStorage.setItem(STORAGE_KEY, typeof stored === "string" ? stored : JSON.stringify(stored)); + } + const frame = document.createElement("iframe"); + frame.style.width = `${width}px`; + const loaded = new Promise((resolve) => frame.addEventListener("load", resolve, { once: true })); + frame.src = `../dist/index.html${search}`; + document.body.append(frame); + await loaded; + + const { contentWindow: win, contentDocument: doc } = frame; + const $ = (selector) => doc.querySelector(selector); + const app = { + win, + doc, + $, + $$: (selector) => [...doc.querySelectorAll(selector)], + click(target) { + const el = typeof target === "string" ? $(target) : target; + assert(el, `nothing matches ${target}`); + el.click(); + }, + names: () => [...doc.querySelectorAll(".product h3")].map((h) => h.textContent), + prices: () => [...doc.querySelectorAll(".product .price")].map((p) => p.textContent), + count: () => $("#count").textContent, + stored: () => JSON.parse(localStorage.getItem(STORAGE_KEY)), + addButton: (index = 0) => doc.querySelectorAll(".product [data-action=add]")[index], + }; + try { + await fn(app); + } finally { + frame.remove(); + localStorage.removeItem(STORAGE_KEY); + } +} +const scenario = (name, fn, options) => test(`app: ${name}`, () => withApp(fn, options)); + +/* ------------------------------------------------------------------ browsing */ + +scenario("renders the whole catalogue with sensible defaults", async (a) => { + equal(a.names(), [ + "Ridgeline Pack", + "Camp Enamel Mug", + "Switchback Cap", + "Alpine Flask", + "Afterglow Lantern", + "Meadow Blanket", + ]); + equal(a.prices(), ["$86", "$24", "$32", "$38", "$54", "$68"]); + equal(a.$("#results").textContent, "6 products shown"); + equal(a.count(), "0"); + equal(a.$("#filters [aria-pressed=true]").textContent, "All"); +}); + +scenario("filtering updates the grid, pressed state, announcement and URL", async (a) => { + a.click('#filters [data-value="Camp"]'); + equal(a.names(), ["Camp Enamel Mug", "Afterglow Lantern", "Meadow Blanket"]); + equal(a.$("#results").textContent, "3 products shown"); + equal(a.$$("#filters [aria-pressed=true]").map((b) => b.textContent), ["Camp"]); + equal(a.win.location.search, "?category=Camp"); + a.click('#filters [data-value="All"]'); + equal(a.win.location.search, "", "default view leaves the URL clean"); +}); + +scenario("sorting by price reorders the grid and updates the URL", async (a) => { + a.$("#sort").value = "low"; + a.$("#sort").dispatchEvent(new Event("change", { bubbles: true })); + equal(a.prices(), ["$24", "$32", "$38", "$54", "$68", "$86"]); + equal(a.win.location.search, "?sort=low"); + a.$("#sort").value = "high"; + a.$("#sort").dispatchEvent(new Event("change", { bubbles: true })); + equal(a.prices(), ["$86", "$68", "$54", "$38", "$32", "$24"]); +}); + +scenario("the view is restored from the URL (shareable links)", async (a) => { + equal(a.names(), ["Switchback Cap"]); + equal(a.$("#sort").value, "high"); + equal(a.$("#filters [aria-pressed=true]").textContent, "Wear"); +}, { search: "?category=Wear&sort=high" }); + +scenario("garbage in the URL is ignored", async (a) => { + equal(a.names().length, 6); + equal(a.$("#sort").value, "featured"); +}, { search: "?category="}
`), + "<script>alert(1)</script>
", + ); +}); + +test("dom: escapes quotes so values cannot break out of attributes", () => { + const out = String(html`x`); + assert(!out.includes('" onmouseover'), `attribute escaped: ${out}`); + equal(escapeHtml(`&<>"'`), "&<>"'"); +}); + +test("dom: nested templates are not double-escaped", () => { + const list = html`${""}
Running…
+