Skip to content

Repository files navigation

Facet

Live at facet.nqvinh.tech.

Facet is a fully client-side e-commerce storefront demo: a faceted catalog of 10,000 products with search, sorting, grid and table views, product detail pages, and a cart. There is no backend and no runtime network request beyond the app's own static assets. Every product - name, brand, category, price, colors, sizes, rating, stock, and image - is generated deterministically at runtime from a single seeded PRNG, so zero bytes of product data are shipped or fetched.

The whole app runs on Vue 3.6 in Vapor mode (createVaporApp): there is no virtual DOM in the bundle. Runtime dependencies are vue and vue-router only.

Guarantees

Each headline claim maps to a proof surface you can run or observe.

# Claim Verified by
1 Zero virtual DOM in the bundle npm run verify:bundle scans an unminified production build: no createVNode/normalizeVNode, Vapor markers present. See the Vapor exception note below.
2 Zero bytes of product data shipped The catalog is a pure function of a seeded PRNG (src/domain/catalog.ts). Math.random is banned by ESLint (npm run lint) and by an E2E guard that replaces it with a throwing function before the app loads (tests/e2e/determinism.spec.ts). Determinism is pinned by unit snapshots (tests/unit/catalog.test.ts).
3 The URL is the only filter state Round-trip unit tests over 200 generated states (tests/unit/url.test.ts) and E2E deep-link restoration, history walking, and garbage-parameter degradation (tests/e2e/url-state.spec.ts). There is no store.
4 Main thread stays free Filtering/search/faceting runs in a Web Worker (src/worker/). The in-app performance HUD (header button or h) shows live input-to-paint latency via the Event Timing API, long-task count, and DOM node count. Measured numbers below.
5 Virtualization Both views window 10,000 products into a few hundred nodes. tests/e2e/virtualization.spec.ts asserts node budgets at top/middle/bottom of both views, stable document height, and zero layout-shift entries while scrolling. The HUD shows the live node count.
6 Real accessible data grid role="grid" with aria-rowcount/aria-rowindex over the full virtual set, roving tabindex, arrows, Home/End, PageUp/PageDown, Ctrl+Home/End across window recycling, and aria-sort headers. Proven by tests/e2e/grid-a11y.spec.ts and the keyboard-only journey.
7 WCAG AA in both themes @axe-core/playwright scans catalog (both views, facets open), product, and cart in light and dark schemes with zero violations (tests/e2e/axe.spec.ts), plus a full keyboard-only journey (tests/e2e/journey.spec.ts).
8 Performance Lighthouse 100/100/100/100 (mobile preset) on all three pages, CLS 0, TBT 0 ms. Total JS 51.9 KB gzip against the 60 KB budget. Methods and raw numbers below.
9 Cart works with zero network Optimistic updates persisted to localStorage, surviving reload and corrupt-storage recovery (tests/e2e/cart.spec.ts), with polite screen-reader announcements on every mutation.
10 View transitions Product-card-to-detail transitions via document.startViewTransition, feature-detected and skipped entirely under prefers-reduced-motion: reduce.

Recorded measurements

All measured on 2026-07-21 against the production build (npm run build) served by vite preview, on an Apple Silicon macOS host, Node v22.16.0.

Bundle size, method: npm run verify:bundle (gzip via Node zlib.gzipSync, marker scan on a separate unminified production build).

  • Entry JS: 139,002 bytes raw, 48,205 bytes gzip.
  • Filter worker: 8,751 bytes raw, 3,714 bytes gzip.
  • Total JS: 147,753 bytes raw, 51,919 bytes gzip (budget: 60 KB gzip, everything included).
  • VDOM markers in unminified output: none. Vapor markers: present.

Lighthouse, method: Lighthouse 12.8.2 via npx lighthouse <url> with default mobile preset, headless Chrome 150, against vite preview on 127.0.0.1.

  • Catalog /: Performance 100, Accessibility 100, Best Practices 100, SEO 100. CLS 0, TBT 0 ms, LCP 1.2 s.
  • Product /p/1234: 100/100/100/100. CLS 0, TBT 0 ms, LCP 1.2 s.
  • Cart /cart: 100/100/100/100. CLS 0, TBT 0 ms, LCP 1.2 s.

Interaction latency, method: PerformanceObserver on Event Timing entries (durationThreshold: 0) in headless Chromium 150 via Playwright, eight consecutive facet-checkbox toggles at 300 ms intervals on the full 10,000-product catalog.

  • Every toggle: 24 ms input to next paint (Event Timing reports in 8 ms granularity).
  • Long tasks during the interaction sequence: 0.
  • The same metrics are observable live in the in-app HUD.

DOM node counts, method: document.querySelectorAll('*').length on the list container and the whole document at scroll top and at result index 5,000 (?at=5000).

  • Grid view list subtree: 318 nodes at top, 440 nodes mid-scroll.
  • Table view list subtree: 321 nodes at top, 385 nodes mid-scroll.
  • Whole page: 683 to 805 nodes while representing 10,000 products.

Test suite: 22 Vitest unit tests, 13 Playwright E2E tests (Chromium), all passing; the E2E suite includes 8 axe scans with zero violations.

Architecture

Deterministic catalog

src/domain/prng.ts derives a per-product seed by avalanche-hashing the world seed 0xFACE7 with the product index (splitmix32 finalizer), then feeds a mulberry32 stream. product(i) in src/domain/catalog.ts is therefore pure and random-access: any product can be generated in any order with identical results, which both the main thread and the worker rely on. Unit tests generate the world twice and compare, pin snapshots of specific products, and verify generation-order independence. Product images are procedural too: src/domain/image.ts derives a layered geometric SVG composition (category motif, palette, placement) from the same stream, rendered inline with fixed dimensions - no image assets, no requests.

Worker and query flow

The worker builds typed-array columns (category/brand as indices, colors/sizes as bitmasks, price/rating/stock/date as numeric arrays) plus a lowercase search haystack over all 10,000 products at startup. Each query returns total count, matching ids as a transferred Uint32Array, and disjunctive facet counts: every facet group is counted with all other groups' filters applied, so each option shows what selecting it would yield. The main thread never materializes the catalog; it renders visible rows by calling product(i) for on-screen indices only. src/state/query.ts watches the route query, debounces only text-search changes (150 ms), tags requests with ids, and drops stale responses.

URL state

src/state/url.ts is the single serializer between FilterState and query params (q, cat, brand, color, size, min, max, rating, avail, sort, view, at). Defaults are omitted, multi-values serialize sorted so equal states produce equal URLs, and parsing is total: invalid input degrades to defaults instead of throwing. Facet interactions use router.push (back/forward walks filter history); the scroll target at uses debounced router.replace.

Virtualization

src/composables/virtual-window.ts windows both views against the document scroller with fixed row heights: one spacer sized count * rowHeight, a visible slice positioned by translateY, and rAF-batched measurement. The grid virtualizes rows of responsive card columns (ResizeObserver); the table virtualizes fixed-height rows, translated individually inside the sized tbody to preserve native table semantics. Fixed dimensions everywhere are what make CLS exactly 0.

Vapor mode and the one exception

The app mounts with createVaporApp; every component is a Vapor SFC (<script setup vapor>). vue-router 5.x is used for matching, history, guards, and scroll behavior, and app.use(router) works under Vapor - but its stock RouterView/RouterLink are VDOM components that cannot render in a pure Vapor tree. They are replaced by two thin Vapor shims (src/router/view.vue, src/router/link.vue), and a guarded Vite transform strips vue-router's dead app.component() registrations so the VDOM runtime cannot be pulled into the bundle; the transform fails the build loudly if vue-router's source ever changes shape. This is the only Vapor exception in the app; NOTES.md records the empirical verification.

Cart, HUD, view transitions

The cart is a reactive module singleton persisted synchronously to localStorage (facet:cart:v1) with validating parse (corrupt storage degrades to empty) and polite live-region announcements. The HUD (src/state/hud.ts) observes Event Timing and long-task entries and samples the DOM node count, excluding its own subtree. Route navigation wraps in document.startViewTransition only when supported and motion is allowed; the clicked card image and the detail hero share a view-transition-name.

Commands

Command What it does
npm run dev Vite dev server.
npm run build Type-check and production build to dist/.
npm run preview Serve the production build.
npm run test Vitest unit suite.
npm run test:e2e Playwright E2E suite (builds are served via vite preview automatically).
npm run lint ESLint (includes the Math.random ban).
npm run verify:bundle Gzip sizes, 60 KB budget check, and VDOM/Vapor marker scan.

Node >= 20.19 is required. The repo pins vue@3.6.0-rc.1; .npmrc sets legacy-peer-deps=true because peer ranges like ^3.5.34 exclude prerelease versions even though the packages are compatible with Vue 3.6.

Hosting

The build output in dist/ is host-agnostic static files. The app is a client-routed SPA, so the host must serve index.html for every non-file route (an SPA fallback rewrite). On Azure Static Web Apps that is a staticwebapp.config.json with a navigationFallback rewrite to /index.html; equivalent settings exist on any static host. Without the fallback, deep links such as /p/1234 return 404 on hard refresh.

This repository ships that configuration in public/, so Vite copies it verbatim into dist/ and it travels with the artifact that is actually deployed. Alongside the fallback it sets a default-src 'none' Content-Security-Policy. Two directives are load-bearing and worth stating explicitly: style-src keeps 'self' for the emitted stylesheet and adds 'unsafe-inline' because Vue writes the virtualization transforms and token overrides as style attributes, and worker-src 'self' covers the module filter worker. Hashed assets under /assets/ and the font are served immutable for a year; the HTML is revalidated on every request.

CI and deployment

.github/workflows/ci.yml runs on every push and pull request: it lints, runs the unit suite, builds the production bundle, checks the bundle guarantees with verify:bundle, and executes the Playwright E2E suite against that build. On pushes to main, the same verified dist/ is uploaded to Azure Static Web Apps with skip_app_build: true, authenticated by the AZURE_STATIC_WEB_APPS_API_TOKEN repository secret. Nothing is rebuilt on the deploy side, so the artifact that ships is byte-for-byte the one the tests and the budget check ran against.

Node is pinned to 22.16.0 via .nvmrc, the version the recorded measurements above were taken on.

Non-goals

Checkout/payments, auth, i18n, SEO prerendering of detail pages, and offline support are intentionally out of scope.

About

Facet - a zero-backend faceted catalog of 10,000 products on Vue 3.6 Vapor. No VDOM, no product data shipped, URL-as-state.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages