@blamejs/blamejs-shop 0.1.22 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.25 (2026-05-25) — **Asset integrity and the cache-buster are correct in production now.** The renderers stamped a Subresource Integrity digest and a `?v=` cache-buster onto the theme stylesheet and island scripts by hashing the files at render time — which silently produced nothing in production, where the container image doesn't ship the theme files and the edge has no filesystem to read them. So the integrity attribute was absent live and edge-rendered pages pinned a `0.0.0` version that never changed between releases (a stylesheet update would render against a browser-cached copy). Both now come from a manifest built at release time and read directly by both runtimes. **Fixed:** *Subresource Integrity now applied on server-rendered pages* — The `integrity="sha384-…"` attribute on the default theme's stylesheet and island scripts was computed by reading the asset off disk during the render. That read fails in the deployed container (its image carries the code, not the `themes/` tree) and at the edge (no filesystem), so the attribute was silently dropped — the tamper-evidence it's meant to provide wasn't active. The digests are now computed once at release time into `lib/asset-manifest.json` (bundled for both the container and the edge worker) and read from there, so the browser actually verifies the served bytes against the pinned hash. · *Edge-rendered pages no longer pin a 0.0.0 cache-buster* — Pages rendered at the edge (home, product, search, the empty cart, blog) appended `?v=0.0.0` to the stylesheet URL because the worker had no release version to stamp — so the cache-buster never changed across deploys and a browser could keep serving an old stylesheet after a CSS update (markup against stale styling). The version now comes from the release manifest, so each release invalidates the cached asset as intended. The drift between the manifest and the on-disk assets is caught by a smoke gate (`generate-asset-manifest.js --check`).
12
+
13
+ - v0.1.23 (2026-05-25) — **The admin console renders styled again, and both surfaces now self-host their fonts.** The admin console shipped its design system as an inline <style> block, inline style="" attributes, and a cross-origin Google Fonts link — all three of which the strict `style-src 'self'` / `font-src 'self'` CSP that governs container-served routes refuses, so the console rendered as an unstyled page in production. Its styling now lives in an external stylesheet served from /assets (like the storefront's), and the typeface is self-hosted, so the console renders as designed under the same CSP that protects it. Both the storefront and the console now serve Inter from /assets rather than naming it and hoping the visitor has it installed. **Changed:** *Inter is self-hosted on both the storefront and the console* — The default theme named Inter / Inter Tight first in its font stack but shipped no font files, so the intended typography only rendered for visitors who happened to have Inter installed locally — everyone else fell back to a system sans-serif. The latin subset of Inter (400/500/600) and Inter Tight (600/700) now ships under `themes/default/assets/fonts` and is referenced via `@font-face`, so the typeface renders for every visitor and loads same-origin under `font-src 'self'` — no cross-origin font CDN on any page. The fonts are SIL OFL 1.1 (license alongside the files); `font-display:swap` paints text immediately in the system fallback and swaps when the file arrives. · *Admin console visual refresh* — With the stylesheet external it also got a pass: a proper centered sign-in card (replacing the bare form that was the first thing an operator saw), softer surfaces and shadows on cards and panels, a sticky console nav, and consistent spacing. The brand palette (ink + the storefront's accent) and every screen's structure are unchanged — this is presentation only. **Fixed:** *Admin console renders unstyled in production* — Every `/admin` screen built its design system three ways the strict CSP refuses: an inline `<style>` block, inline `style=""` attributes, and a `<link>` to fonts.googleapis.com. Container-served routes run under `style-src 'self'` / `font-src 'self'`, which drops all three — so the console arrived as unstyled HTML (a plain page) once deployed, even though it looked correct in isolation. The styling now ships as `/assets/themes/default/css/admin.css`, an external file `'self'` allows, linked with a `sha384` Subresource Integrity digest so the browser rejects a tampered or stale object; the inline attributes are gone, folded into that stylesheet. A render-shell test asserts every admin screen links the external stylesheet, carries no inline styles, and pulls no third-party font host, so the regression can't return. Operators redeploy to pick this up — `npm run deploy` uploads the new stylesheet (and the fonts below) to R2 as part of the asset sync.
14
+
11
15
  - v0.1.22 (2026-05-25) — **Fix the container boot crash-loop, plus gift cards, subscription self-management, and a customers roster.** The deployable container could crash-loop on startup, taking every write route (add-to-cart, checkout, account, admin) down while edge-rendered reads kept working — this release fixes that boot path. It also adds gift cards (issue / balance / redeem-at-checkout with an operator ledger), customer subscription self-management at `/account/subscriptions`, and a read-only customers roster in the admin console. **Added:** *Gift cards — redeem at checkout, balance check, operator ledger* — A gift-card code can be applied at checkout as a credit against the order total: the amount due drops by the available balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order behind an atomic `balance >= amount` guard so concurrent spends can't overdraw — a card that fully covers the order is marked paid with no card charge. Customers check a balance at `GET /gift-cards` (which is not a code-existence oracle — unknown / malformed / expired all return the same generic result). Operators work `/admin/gift-cards`: list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a card (the bearer code shown once, right after creation), and open a card to read its full credit / debit / expire ledger. Codes are stored as a `namespaceHash` digest plus a 4-char hint — never in the clear. · *Customer subscription self-management* — Signed-in customers view and cancel their own subscriptions at `/account/subscriptions` — each row shows the plan summary, status, and renew / end date, with a Cancel control on cancelable subscriptions. Every action is ownership-checked: a subscription that doesn't belong to the signed-in customer is a 404, never another account's row. Cancellation runs through the payment provider, so the cancel control mounts only when the payment handle is wired; without it the list renders read-only. Customer-facing subscription creation — a subscription-checkout flow — remains a separate follow-up; this surface is for managing existing subscriptions. · *Customers admin roster* — `/admin/customers` renders a read-only account roster (newest first): display name, a short id, the join date, the sign-in method (passkey count plus any linked OAuth providers), and the order count, with cursor pagination in the browser and a JSON list (with `next_cursor`) for a bearer-token client. Account creation and changes stay with the storefront's passkey / OIDC ceremonies. Order counts and sign-in methods are resolved with bounded aggregate queries over each page's ids — no per-row database trips. Raw email is never stored, so it is never shown. **Changed:** *Console nav gains Customers and Gift cards* — The signed-in admin nav now includes Customers and Gift cards alongside the existing screens, each shown when its primitive is wired. All new endpoints content-negotiate like the rest of the console: a bearer-token client gets the JSON API, a signed-in browser gets HTML; a request without the bearer token returns the sign-in form on a GET and redirects on a write. The customers roster, the subscription cancel, and the gift-card balance page all degrade gracefully when an optional dependency (payment, or the primitive itself) isn't configured. · *Deploy now syncs theme assets to R2* — `npm run deploy` chains `wrangler deploy` with a new `scripts/sync-r2-assets.js` that uploads the repo's theme stylesheets to the R2 bucket the Worker serves `/assets/*` from. `wrangler deploy` alone ships the Worker + container but never touches R2, so a CSS change would silently drift from the deployed HTML — new markup against a stale stylesheet. Run `npm run sync-assets` to push the stylesheets without a full redeploy. · *Theme stylesheet and island scripts served with Subresource Integrity* — The default theme's stylesheet `<link>` and the externalized passkey island `<script>` tags now carry a `sha384` `integrity` attribute computed from the on-disk asset, so a browser refuses any `/assets/*` response whose bytes don't match — closing the window where a tampered or stale CDN object could execute against the page. The digests are computed once at boot and cached; a missing or unreadable asset degrades to no integrity attribute rather than blocking the render. **Fixed:** *Passkey sign-in / registration no longer blocked by CSP* — The WebAuthn passkey ceremonies on `/account/login` and `/account/register` were inline `<script>` blocks, which the storefront's strict `script-src 'self'` CSP refuses to execute — so on the deploy the sign-in and registration buttons did nothing (accounts are passkey-only, so this blocked all customer auth). The ceremonies now ship as external assets (`/assets/themes/default/js/passkey-login.js` + `passkey-register.js`) that `'self'` allows, served from R2 and uploaded by the deploy's asset sync. Same effect, CSP-clean — and a clearer split between the server-rendered form and its opt-in enhancement. · *Container no longer crash-loops on startup* — The container boot had three ways to fail before it could serve a request, each of which left edge-rendered reads working while every container-served route (add-to-cart, cart update / remove, checkout, `/account`, `/admin`) was unreachable. (1) The secure-default boot unlocks a wrapped vault **and** a wrapped audit-signing keypair, which the framework reads from `BLAMEJS_VAULT_PASSPHRASE` / `BLAMEJS_AUDIT_SIGNING_PASSPHRASE`, but the deploy contract documents a single `VAULT_PASSPHRASE` the framework never read — so the container had no passphrase source, no TTY to prompt, and exited. The entry point now bridges the documented secret onto the vault passphrase and derives a domain-separated audit-signing passphrase from it. (2) Encrypted-at-rest requires a tmpfs; the image now points it at `/dev/shm` rather than failing to find one. (3) The Docker liveness probe was `wget`, which bot-guard blocks as automation (403), so a healthy container was marked unhealthy and stopped — the probe is now a browser-shaped Node request (`scripts/healthcheck.js`) that passes bot-guard like real traffic, without exempting the health path from the security middleware. A boot gate (`test/layer-2-integration/server-boot.test.js`) spawns the real entry point and asserts it listens, answers the probe, and still blocks header-less clients. Operators must redeploy the container to pick this up.
12
16
 
13
17
  - v0.1.21 (2026-05-25) — **Admin console — a collections screen.** Collections join the admin console. `/admin/collections` lists manual and smart product collections, creates either kind, edits title / description / sort strategy, archives one, manages a manual collection's members (add / remove / reorder), and builds a smart collection's rules with a live preview of the products they match — all from a signed-in browser. As with the other console screens, the same paths serve the existing JSON API to a bearer-token client unchanged. **Added:** *Collections management screen* — `/admin/collections` lists the shop's collections (title, slug, type, status, member or match count) with an all / active / archived filter; `/admin/collections/:slug` opens one. For a manual collection it manages members — add a product by id, remove, and reorder. For a smart collection it edits the rule set (field / operator / value rows over the supported fields and operators) and renders a live preview of the products the rules currently match. A form creates either kind; editing updates the title, description, and sort strategy (and rules for smart collections); and a collection can be archived. A bad-shape submit re-renders with the validator's message rather than a 500, and an unknown slug or product is a no-op notice. **Changed:** *Console nav gains Collections* — The signed-in admin nav now includes Collections, shown when the collections primitive is wired. The list, detail, create, edit, archive, and member endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token returns the sign-in form on a GET and redirects on a write.
package/README.md CHANGED
@@ -61,7 +61,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
61
61
  | **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
62
62
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
63
63
  | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
64
- | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
64
+ | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
65
65
  | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront. |
66
66
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
67
67
  | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
@@ -75,7 +75,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
75
75
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
76
76
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
77
77
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
78
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Customers, Returns, Reviews, Subscriptions, Collections, Gift cards, and Webhooks links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. |
78
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Customers, Returns, Reviews, Subscriptions, Collections, Gift cards, and Webhooks links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
79
79
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
80
80
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
81
81
 
package/lib/admin.js CHANGED
@@ -37,6 +37,29 @@ var b = require("./vendor/blamejs");
37
37
 
38
38
  var AUDIT_NAMESPACE = "shop_admin";
39
39
 
40
+ // The console stylesheet ships as an external /assets file — an inline
41
+ // <style> block (or inline style="" attributes) is refused by the strict
42
+ // `style-src 'self'` CSP that governs container-served routes, which is
43
+ // why the console renders unstyled when those are inlined. `'self'`
44
+ // allows this file; the deploy's R2 asset sync uploads it. The `?v=` is
45
+ // the per-release cache-buster; the sha384 Subresource Integrity digest
46
+ // (b.crypto.sri, W3C SRI 1.0) makes the browser reject a tampered or
47
+ // stale object. Same-origin, so no `crossorigin` is needed. The digest +
48
+ // version come from the build-time manifest (lib/asset-manifest.json)
49
+ // rather than hashing the file at render time, so they're present in the
50
+ // container image too — which doesn't ship the theme asset files. A key
51
+ // absent from the manifest yields no attribute.
52
+ var _assetManifest = require("./asset-manifest.json");
53
+ var _ASSET_VERSION = _assetManifest.version;
54
+ function _assetSri(relUnderThemeAssets) {
55
+ return _assetManifest.assets[relUnderThemeAssets] || null;
56
+ }
57
+ function _adminStylesheetLink() {
58
+ var sri = _assetSri("css/admin.css");
59
+ return "<link rel=\"stylesheet\" href=\"/assets/themes/default/css/admin.css?v=" + _ASSET_VERSION + "\"" +
60
+ (sri ? " integrity=\"" + sri + "\"" : "") + ">";
61
+ }
62
+
40
63
  // Conservative content-type → file-extension map for the upload route.
41
64
  // Unknown types fall back to no extension; the R2 object metadata still
42
65
  // carries the full content-type so the asset serves correctly either
@@ -2249,97 +2272,13 @@ var DASHBOARD_LAYOUT =
2249
2272
  " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
2250
2273
  " <meta name=\"robots\" content=\"noindex,nofollow\">\n" +
2251
2274
  " <title>Admin dashboard — {{shop_name}}</title>\n" +
2252
- " <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n" +
2253
- " <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n" +
2254
- " <link href=\"https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600;700&family=Inter:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n" +
2255
- " <style>\n" +
2256
- " :root { --ink:#191919; --ink-2:#414141; --mute:#727272; --hair:#d9d9d9; --paper:#ffffff; --bg:#fafafa; --accent:#fa4f09; --accent-d:#d8410a; }\n" +
2257
- " * { box-sizing: border-box; }\n" +
2258
- " html, body { margin:0; padding:0; background:var(--bg); }\n" +
2259
- " body { font-family:'Inter',ui-sans-serif,system-ui,sans-serif; color:var(--ink); font-size:15px; line-height:1.55; }\n" +
2260
- " h1, h2, h3 { font-family:'Montserrat',sans-serif; font-weight:700; letter-spacing:-0.01em; margin:0 0 .65rem; }\n" +
2261
- " .admin-header { background:var(--ink); color:var(--paper); border-bottom:3px solid var(--accent); }\n" +
2262
- " .admin-header__inner { max-width:80rem; margin:0 auto; padding:1.2rem 1.5rem; display:flex; align-items:center; justify-content:space-between; }\n" +
2263
- " .admin-header h1 { color:var(--paper); font-size:1.1rem; margin:0; font-weight:600; letter-spacing:.02em; text-transform:uppercase; }\n" +
2264
- " .admin-header .brand-accent { color:var(--accent); }\n" +
2265
- " main { max-width:80rem; margin:0 auto; padding:2.5rem 1.5rem 5rem; }\n" +
2266
- " section { margin-bottom:2.5rem; }\n" +
2267
- " section h2 { font-size:1.1rem; text-transform:uppercase; letter-spacing:.05em; color:var(--mute); font-weight:600; margin-bottom:1rem; }\n" +
2268
- " .stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(12rem, 1fr)); gap:1rem; }\n" +
2269
- " .stat-card { background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.25rem 1.4rem; }\n" +
2270
- " .stat-card .label { font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; color:var(--mute); font-weight:600; }\n" +
2271
- " .stat-card .value { font-family:'Montserrat',sans-serif; font-weight:700; font-size:1.8rem; color:var(--ink); margin-top:.35rem; line-height:1.1; }\n" +
2272
- " .stat-card .value.accent { color:var(--accent); }\n" +
2273
- " .panel { background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.5rem; }\n" +
2274
- " .two-col { display:grid; grid-template-columns: 2fr 1fr; gap:1.5rem; align-items:start; }\n" +
2275
- " @media (max-width: 56rem) { .two-col { grid-template-columns: 1fr; } }\n" +
2276
- " table { width:100%; border-collapse:collapse; font-size:.9rem; }\n" +
2277
- " thead th { text-align:left; padding:.65rem .75rem; border-bottom:2px solid var(--ink); font-family:'Montserrat',sans-serif; font-weight:600; font-size:.72rem; letter-spacing:.05em; text-transform:uppercase; color:var(--mute); }\n" +
2278
- " tbody td { padding:.65rem .75rem; border-bottom:1px solid var(--hair); }\n" +
2279
- " tbody tr:last-child td { border-bottom:none; }\n" +
2280
- " td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }\n" +
2281
- " .status-pill { display:inline-block; padding:.15rem .55rem; border-radius:999px; font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.04em; background:var(--bg); color:var(--ink-2); border:1px solid var(--hair); }\n" +
2282
- " .status-pill.paid, .status-pill.fulfilling, .status-pill.shipped, .status-pill.delivered { background:#e9f5ec; color:#1f6b3a; border-color:#bfe1c9; }\n" +
2283
- " .status-pill.refunded { background:#fff1eb; color:var(--accent-d); border-color:#f6c5af; }\n" +
2284
- " .status-pill.cancelled { background:#f4f4f4; color:var(--mute); }\n" +
2285
- " .status-pill.pending { background:#fff8e1; color:#7a5d0f; border-color:#f1e1a8; }\n" +
2286
- " .spark { width:100%; height:8rem; background:var(--bg); border:1px solid var(--hair); border-radius:6px; padding:.5rem; }\n" +
2287
- " .spark svg { display:block; width:100%; height:100%; }\n" +
2288
- " .empty { color:var(--mute); font-style:italic; padding:1rem 0; text-align:center; }\n" +
2289
- " .meta { color:var(--mute); font-size:.85rem; margin-bottom:1rem; }\n" +
2290
- " .order-id { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.78rem; color:var(--ink-2); }\n" +
2291
- " .form-field { display:block; margin-bottom:1.1rem; }\n" +
2292
- " .form-field span { display:block; font-size:.78rem; text-transform:uppercase; letter-spacing:.05em; color:var(--mute); font-weight:600; margin-bottom:.35rem; }\n" +
2293
- " .form-field input { width:100%; max-width:28rem; padding:.6rem .75rem; border:1px solid var(--hair); border-radius:6px; font:inherit; }\n" +
2294
- " .form-field input:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent); }\n" +
2295
- " .form-field small { display:block; color:var(--mute); font-size:.78rem; margin-top:.3rem; }\n" +
2296
- " .btn { display:inline-flex; align-items:center; gap:.4rem; background:var(--accent); color:var(--paper); border:1px solid var(--accent); padding:.6rem 1.1rem; border-radius:6px; font-family:'Montserrat',sans-serif; font-weight:700; font-size:.82rem; letter-spacing:.04em; text-transform:uppercase; text-decoration:none; cursor:pointer; }\n" +
2297
- " .btn:hover { background:var(--accent-d); border-color:var(--accent-d); }\n" +
2298
- " .btn--ghost { background:transparent; color:var(--ink); border-color:var(--ink); }\n" +
2299
- " .btn--ghost:hover { background:var(--ink); color:var(--paper); }\n" +
2300
- " .btn--danger { background:transparent; color:var(--accent-d); border-color:var(--accent-d); }\n" +
2301
- " .btn--danger:hover { background:var(--accent-d); color:var(--paper); }\n" +
2302
- " .order-filters { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:1.25rem; }\n" +
2303
- " .chip { display:inline-block; padding:.3rem .8rem; border-radius:999px; border:1px solid var(--hair); color:var(--ink-2); text-decoration:none; font-size:.78rem; text-transform:capitalize; }\n" +
2304
- " .chip:hover { border-color:var(--accent); }\n" +
2305
- " .chip--on { background:var(--ink); color:var(--paper); border-color:var(--ink); }\n" +
2306
- " .order-totals { width:100%; }\n" +
2307
- " .order-totals td { padding:.3rem 0; }\n" +
2308
- " .order-actions { display:flex; flex-wrap:wrap; gap:.6rem; }\n" +
2309
- " .return-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1.25rem; }\n" +
2310
- " .return-action { border:1px solid var(--hair); border-radius:8px; padding:1rem; }\n" +
2311
- " .return-action h4 { margin:0 0 .6rem; font-size:.9rem; }\n" +
2312
- " .review-card { margin-bottom:1rem; }\n" +
2313
- " .review-card__head { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:.5rem; }\n" +
2314
- " .review-card__body { margin:.25rem 0 .75rem; white-space:pre-wrap; }\n" +
2315
- " .review-stars { color:#c9821f; letter-spacing:.1em; }\n" +
2316
- " .review-reject { display:inline-flex; gap:.4rem; align-items:center; }\n" +
2317
- " .review-reject input { padding:.45rem .6rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
2318
- " .inv-row-form { display:flex; gap:.4rem; align-items:center; }\n" +
2319
- " .inv-row-form input { padding:.4rem .5rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
2320
- " tr.row--low td { background:#fff8e1; }\n" +
2321
- " .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
2322
- " .nav-card { display:block; background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.4rem; text-decoration:none; color:var(--ink); }\n" +
2323
- " .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
2324
- " .nav-card h3 { margin:0 0 .35rem; font-size:1.05rem; }\n" +
2325
- " .nav-card p { margin:0; color:var(--mute); font-size:.88rem; }\n" +
2326
- " .banner { padding:.9rem 1.1rem; border-radius:8px; margin-bottom:1.5rem; font-size:.92rem; }\n" +
2327
- " .banner--warn { background:#fff8e1; border:1px solid #f1e1a8; color:#7a5d0f; }\n" +
2328
- " .banner--ok { background:#e9f5ec; border:1px solid #bfe1c9; color:#1f6b3a; }\n" +
2329
- " .banner--err { background:#fff1eb; border:1px solid #f6c5af; color:var(--accent-d); }\n" +
2330
- " .actions-row { display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; margin-top:1.5rem; }\n" +
2331
- " .admin-nav { background:var(--paper); border-bottom:1px solid var(--hair); }\n" +
2332
- " .admin-nav__inner { max-width:80rem; margin:0 auto; padding:0 1.5rem; display:flex; gap:.1rem; flex-wrap:wrap; }\n" +
2333
- " .admin-nav a { display:inline-block; padding:.85rem .9rem; color:var(--ink-2); text-decoration:none; font-size:.84rem; font-weight:600; border-bottom:2px solid transparent; }\n" +
2334
- " .admin-nav a:hover { color:var(--ink); }\n" +
2335
- " .admin-nav a.active { color:var(--accent); border-bottom-color:var(--accent); }\n" +
2336
- " </style>\n" +
2275
+ " RAW_ADMIN_CSS\n" +
2337
2276
  "</head>\n" +
2338
2277
  "<body>\n" +
2339
2278
  " <header class=\"admin-header\">\n" +
2340
2279
  " <div class=\"admin-header__inner\">\n" +
2341
2280
  " <h1>{{shop_name}} <span class=\"brand-accent\">/ admin</span></h1>\n" +
2342
- " <span style=\"font-size:.8rem; color:var(--mute);\">{{window_label}}</span>\n" +
2281
+ " <span class=\"window-label\">{{window_label}}</span>\n" +
2343
2282
  " </div>\n" +
2344
2283
  " </header>\n" +
2345
2284
  " {{nav}}\n" +
@@ -2460,11 +2399,11 @@ function renderDashboard(opts) {
2460
2399
  var twoCol =
2461
2400
  "<section><h2>Catalog + activity</h2><div class=\"two-col\">" +
2462
2401
  " <div class=\"panel\">" +
2463
- " <h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Top SKUs by units sold</h3>" +
2402
+ " <h3 class=\"subhead\">Top SKUs by units sold</h3>" +
2464
2403
  " <table><thead><tr><th>SKU</th><th class=\"num\">Units</th><th class=\"num\">Revenue</th></tr></thead><tbody>" + topRows + "</tbody></table>" +
2465
2404
  " </div>" +
2466
2405
  " <div class=\"panel\">" +
2467
- " <h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Recent orders</h3>" +
2406
+ " <h3 class=\"subhead\">Recent orders</h3>" +
2468
2407
  " <table><thead><tr><th>Order</th><th>Status</th><th class=\"num\">Total</th></tr></thead><tbody>" + recentRows + "</tbody></table>" +
2469
2408
  " </div>" +
2470
2409
  "</div></section>";
@@ -2530,7 +2469,9 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
2530
2469
  window_label: subtitle || "",
2531
2470
  nav: "RAW_NAV",
2532
2471
  body: "RAW_BODY",
2533
- }).replace("RAW_NAV", _adminNav(active, available)).replace("RAW_BODY", bodyHtml);
2472
+ }).replace("RAW_ADMIN_CSS", _adminStylesheetLink())
2473
+ .replace("RAW_NAV", _adminNav(active, available))
2474
+ .replace("RAW_BODY", bodyHtml);
2534
2475
  }
2535
2476
 
2536
2477
  function renderAdminLogin(opts) {
@@ -2538,18 +2479,24 @@ function renderAdminLogin(opts) {
2538
2479
  var err = opts.error
2539
2480
  ? "<div class=\"banner banner--err\">That key didn't match. Check the ADMIN_API_KEY this deployment was started with.</div>"
2540
2481
  : "";
2482
+ var shopName = opts.shop_name || "blamejs.shop";
2541
2483
  var body =
2542
- "<section style=\"max-width:30rem;\">" +
2543
- "<h2>Sign in</h2>" + err +
2544
- "<form method=\"post\" action=\"/admin/login\">" +
2545
- "<label class=\"form-field\"><span>Admin API key</span>" +
2546
- "<input type=\"password\" name=\"token\" autocomplete=\"off\" autofocus required>" +
2547
- "<small>Paste the ADMIN_API_KEY this deployment was started with.</small>" +
2548
- "</label>" +
2549
- "<button type=\"submit\" class=\"btn\">Sign in</button>" +
2550
- "</form>" +
2551
- "</section>";
2552
- return _renderAdminShell(opts.shop_name, "Sign in", body, null);
2484
+ "<div class=\"signin-wrap\">" +
2485
+ "<div class=\"signin-card\">" +
2486
+ "<div class=\"signin-mark\"><span class=\"dot\"></span>" + _htmlEscape(shopName) + " admin</div>" +
2487
+ "<h2>Sign in</h2>" +
2488
+ "<p class=\"signin-lede\">This console manages your shop's catalog, orders, and settings.</p>" +
2489
+ err +
2490
+ "<form method=\"post\" action=\"/admin/login\">" +
2491
+ "<label class=\"form-field\"><span>Admin API key</span>" +
2492
+ "<input type=\"password\" name=\"token\" autocomplete=\"off\" autofocus required>" +
2493
+ "<small>The ADMIN_API_KEY this deployment was started with.</small>" +
2494
+ "</label>" +
2495
+ "<button type=\"submit\" class=\"btn\">Sign in</button>" +
2496
+ "</form>" +
2497
+ "</div>" +
2498
+ "</div>";
2499
+ return _renderAdminShell(shopName, "Sign in", body, null);
2553
2500
  }
2554
2501
 
2555
2502
  function renderAdminLanding(opts) {
@@ -2583,14 +2530,14 @@ function renderAdminSetup(opts) {
2583
2530
  var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved. Your shop details are live.</div>" : "";
2584
2531
  var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
2585
2532
  var body =
2586
- "<section style=\"max-width:34rem;\">" +
2533
+ "<section class=\"mw-34\">" +
2587
2534
  "<h2>Shop setup</h2>" +
2588
2535
  "<p class=\"meta\">Set the basics customers see across the storefront. You can change these any time.</p>" +
2589
2536
  saved + notice +
2590
2537
  "<form method=\"post\" action=\"/admin/setup\">" +
2591
2538
  _setupField("Shop name", "shop_name", v.shop_name, "text", "Shown in the header, page titles, and emails.", " maxlength=\"80\" required") +
2592
2539
  _setupField("Contact email", "contact_email", v.contact_email, "email", "Where customer replies and operational mail land.", " maxlength=\"160\"") +
2593
- _setupField("Default currency", "currency", v.currency, "text", "3-letter ISO 4217 code (e.g. USD, EUR, GBP).", " maxlength=\"3\" style=\"text-transform:uppercase;max-width:8rem;\"") +
2540
+ _setupField("Default currency", "currency", v.currency, "text", "3-letter ISO 4217 code (e.g. USD, EUR, GBP).", " maxlength=\"3\" class=\"input-code\"") +
2594
2541
  _setupField("Support URL", "support_url", v.support_url, "url", "Linked from the storefront footer (help centre, contact page).", " maxlength=\"300\"") +
2595
2542
  "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save shop details</button>" +
2596
2543
  "<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
@@ -2649,7 +2596,7 @@ function renderAdminIntegrations(opts) {
2649
2596
  "<thead><tr><th>Integration</th><th>Status</th><th>To enable</th></tr></thead>" +
2650
2597
  "<tbody>" + rows + "</tbody>" +
2651
2598
  "</table></div>" +
2652
- "<p class=\"meta\" style=\"margin-top:1.25rem;\">Sign in with Apple and PayPal are planned. “Sign in with Shop” / Shop Pay isn't available to a self-hosted store. See the README “Optional integrations” section for full setup steps.</p>" +
2599
+ "<p class=\"meta mt-125\">Sign in with Apple and PayPal are planned. “Sign in with Shop” / Shop Pay isn't available to a self-hosted store. See the README “Optional integrations” section for full setup steps.</p>" +
2653
2600
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
2654
2601
  "</section>";
2655
2602
  return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations", opts.nav_available);
@@ -2675,8 +2622,8 @@ function renderAdminProducts(opts) {
2675
2622
  : "<p class=\"empty\">No products yet — create your first one below.</p>";
2676
2623
  var body =
2677
2624
  "<section><h2>Products</h2>" + created + notice + table +
2678
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
2679
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">New product</h3>" +
2625
+ "<div class=\"panel mt mw-34\">" +
2626
+ "<h3 class=\"subhead\">New product</h3>" +
2680
2627
  "<form method=\"post\" action=\"/admin/products\">" +
2681
2628
  _setupField("Title", "title", "", "text", "", " maxlength=\"200\" required") +
2682
2629
  _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — the storefront URL.", " maxlength=\"200\" required") +
@@ -2780,12 +2727,12 @@ function renderAdminOrder(opts) {
2780
2727
  var actionForms = transitions.map(function (t) {
2781
2728
  if (t.on === "refund") {
2782
2729
  if (!opts.can_refund) return ""; // no payment intent — nothing to refund here
2783
- return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" style=\"display:inline;\">" +
2730
+ return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" class=\"form-inline\">" +
2784
2731
  "<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
2785
2732
  "</form>";
2786
2733
  }
2787
2734
  var danger = (t.on === "cancel");
2788
- return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\" style=\"display:inline;\">" +
2735
+ return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\" class=\"form-inline\">" +
2789
2736
  "<input type=\"hidden\" name=\"event\" value=\"" + _htmlEscape(t.on) + "\">" +
2790
2737
  "<button class=\"btn" + (danger ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
2791
2738
  "</form>";
@@ -2793,7 +2740,7 @@ function renderAdminOrder(opts) {
2793
2740
  var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
2794
2741
 
2795
2742
  var body =
2796
- "<section style=\"max-width:48rem;\">" +
2743
+ "<section class=\"mw-48\">" +
2797
2744
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">&larr; Orders</a></div>" +
2798
2745
  "<h2>Order <code class=\"order-id\">" + _htmlEscape(o.id.slice(0, 8)) + "</code> " +
2799
2746
  "<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
@@ -2801,13 +2748,13 @@ function renderAdminOrder(opts) {
2801
2748
  (o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
2802
2749
  moved + notice +
2803
2750
  "<div class=\"two-col\">" +
2804
- "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
2805
- "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Ship to</h3>" +
2751
+ "<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
2752
+ "<div class=\"panel\"><h3 class=\"subhead\">Ship to</h3>" +
2806
2753
  (shipLines || "<span class=\"meta\">No shipping address.</span>") +
2807
- "<h3 style=\"font-size:.95rem; margin:1.25rem 0 .75rem;\">Totals</h3>" + totals +
2754
+ "<h3 class=\"subhead subhead--sp\">Totals</h3>" + totals +
2808
2755
  "</div>" +
2809
2756
  "</div>" +
2810
- "<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
2757
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
2811
2758
  "<div class=\"order-actions\">" + actions + "</div>" +
2812
2759
  "</div>" +
2813
2760
  "</section>";
@@ -2946,7 +2893,7 @@ function renderAdminReturn(opts) {
2946
2893
  "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/approve\" class=\"return-action\">" +
2947
2894
  "<h4>Approve</h4>" +
2948
2895
  _setupField("Refund amount (minor units)", "refund_amount_minor", "", "number", "e.g. 4999 for $49.99.", " min=\"0\" required") +
2949
- _setupField("Refund currency", "refund_currency", r.refund_currency || "USD", "text", "3-letter ISO 4217.", " maxlength=\"3\" style=\"text-transform:uppercase;max-width:8rem;\"") +
2896
+ _setupField("Refund currency", "refund_currency", r.refund_currency || "USD", "text", "3-letter ISO 4217.", " maxlength=\"3\" class=\"input-code\"") +
2950
2897
  _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
2951
2898
  "<button class=\"btn\" type=\"submit\">Approve return</button>" +
2952
2899
  "</form>");
@@ -2981,7 +2928,7 @@ function renderAdminReturn(opts) {
2981
2928
  : "<span class=\"meta\">This return is in a final state — no further changes.</span>";
2982
2929
 
2983
2930
  var body =
2984
- "<section style=\"max-width:48rem;\">" +
2931
+ "<section class=\"mw-48\">" +
2985
2932
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/returns\">&larr; Returns</a></div>" +
2986
2933
  "<h2>Return <code class=\"order-id\">" + _htmlEscape(r.rma_code || r.id.slice(0, 8)) + "</code> " +
2987
2934
  "<span class=\"status-pill " + _returnPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></h2>" +
@@ -2989,8 +2936,8 @@ function renderAdminReturn(opts) {
2989
2936
  " · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(r.order_id) + "\">" + _htmlEscape(String(r.order_id).slice(0, 8)) + "</a></p>" +
2990
2937
  moved + notice +
2991
2938
  "<div class=\"two-col\">" +
2992
- "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
2993
- "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Details</h3>" +
2939
+ "<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
2940
+ "<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
2994
2941
  _field("Reason", r.reason) +
2995
2942
  _field("Customer detail", r.reason_detail) +
2996
2943
  _field("Customer notes", r.customer_notes) +
@@ -2999,7 +2946,7 @@ function renderAdminReturn(opts) {
2999
2946
  (r.rejected_reason ? _field("Rejection reason", r.rejected_reason) : "") +
3000
2947
  "</div>" +
3001
2948
  "</div>" +
3002
- "<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
2949
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
3003
2950
  actions +
3004
2951
  "</div>" +
3005
2952
  "</section>";
@@ -3032,7 +2979,7 @@ function renderAdminReviews(opts) {
3032
2979
  // sense from its current status (a rejected review can be published, a
3033
2980
  // published one taken down, a pending one either way).
3034
2981
  var cards = list.map(function (rv) {
3035
- var pub = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/publish\" style=\"display:inline;\">" +
2982
+ var pub = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/publish\" class=\"form-inline\">" +
3036
2983
  "<button class=\"btn\" type=\"submit\">Publish</button></form>";
3037
2984
  var rej = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/reject\" class=\"review-reject\">" +
3038
2985
  "<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
@@ -3083,8 +3030,8 @@ function renderAdminInventory(opts) {
3083
3030
  "<td class=\"num\"><strong>" + _htmlEscape(String(available)) + "</strong></td>" +
3084
3031
  "<td>" +
3085
3032
  "<form method=\"post\" action=\"/admin/inventory/" + _htmlEscape(r.sku) + "/restock\" class=\"inv-row-form\">" +
3086
- "<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\" style=\"width:6rem;\">" +
3087
- "<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\" style=\"width:6rem;\">" +
3033
+ "<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\" class=\"input-narrow\">" +
3034
+ "<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\" class=\"input-narrow\">" +
3088
3035
  "<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
3089
3036
  "</form>" +
3090
3037
  "</td></tr>";
@@ -3095,8 +3042,8 @@ function renderAdminInventory(opts) {
3095
3042
  : "<p class=\"empty\">No inventory rows" + (opts.low ? " below threshold" : " yet") + ".</p>";
3096
3043
 
3097
3044
  var createForm =
3098
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
3099
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Track a new SKU</h3>" +
3045
+ "<div class=\"panel mt mw-34\">" +
3046
+ "<h3 class=\"subhead\">Track a new SKU</h3>" +
3100
3047
  "<form method=\"post\" action=\"/admin/inventory\">" +
3101
3048
  _setupField("SKU", "sku", "", "text", "Must match a variant SKU.", " maxlength=\"128\" required") +
3102
3049
  _setupField("Starting stock on hand", "stock_on_hand", "0", "number", "", " min=\"0\"") +
@@ -3133,7 +3080,7 @@ function renderAdminSubscriptionPlans(opts) {
3133
3080
  var price = pricing.format(p.amount_minor, String(p.currency || "").toUpperCase()) + " / " + every;
3134
3081
  var isActive = p.active === 1 || p.active === true;
3135
3082
  var archiveCell = isActive
3136
- ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive\" style=\"display:inline;\">" +
3083
+ ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive\" class=\"form-inline\">" +
3137
3084
  "<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>"
3138
3085
  : "<span class=\"meta\">—</span>";
3139
3086
  return "<tr>" +
@@ -3155,8 +3102,8 @@ function renderAdminSubscriptionPlans(opts) {
3155
3102
  }).join("");
3156
3103
 
3157
3104
  var createForm =
3158
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
3159
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Create a plan</h3>" +
3105
+ "<div class=\"panel mt mw-34\">" +
3106
+ "<h3 class=\"subhead\">Create a plan</h3>" +
3160
3107
  "<p class=\"meta\">Pre-create the recurring Price in Stripe, then mirror it here so the storefront can render the plan without a network hop.</p>" +
3161
3108
  "<form method=\"post\" action=\"/admin/subscription-plans\">" +
3162
3109
  _setupField("Stripe price id", "stripe_price_id", "", "text", "The recurring Price id from your Stripe dashboard (e.g. price_…).", " maxlength=\"255\" required") +
@@ -3222,8 +3169,8 @@ function renderAdminGiftCards(opts) {
3222
3169
  // plaintext code is shown once on the card's detail page after a
3223
3170
  // successful issue, never again.
3224
3171
  var issueForm =
3225
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
3226
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Issue a card</h3>" +
3172
+ "<div class=\"panel mt mw-34\">" +
3173
+ "<h3 class=\"subhead\">Issue a card</h3>" +
3227
3174
  "<p class=\"meta\">Generates a bearer code. You'll see the code once, on the next screen — deliver it to the recipient; it can't be shown again.</p>" +
3228
3175
  "<form method=\"post\" action=\"/admin/gift-cards\">" +
3229
3176
  _setupField("Amount (minor units)", "amount_minor", "", "number", "In the currency's smallest unit — e.g. 5000 = $50.00.", " min=\"1\" required") +
@@ -3284,7 +3231,7 @@ function renderAdminGiftCard(opts) {
3284
3231
  : "<p class=\"empty\">No ledger transactions recorded for this card yet.</p>";
3285
3232
 
3286
3233
  var body = "<section><h2>Gift card</h2>" + issuedBanner + summary +
3287
- "<h3 style=\"font-size:.95rem; margin:1.5rem 0 .75rem;\">Ledger</h3>" + ledgerTable +
3234
+ "<h3 class=\"subhead subhead--sp-lg\">Ledger</h3>" + ledgerTable +
3288
3235
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a></div></section>";
3289
3236
  return _renderAdminShell(opts.shop_name, "Gift card", body, "giftcards", opts.nav_available);
3290
3237
  }
@@ -3309,8 +3256,8 @@ function renderAdminWebhooks(opts) {
3309
3256
  "<td class=\"num\">" + _htmlEscape(String(e.rate_limit_per_minute)) + "/min</td>" +
3310
3257
  "<td>" +
3311
3258
  "<a class=\"btn btn--ghost\" href=\"/admin/webhooks/" + _htmlEscape(e.id) + "/deliveries\">Deliveries</a> " +
3312
- "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\" style=\"display:inline;\"><button class=\"btn btn--ghost\" type=\"submit\">" + (isActive ? "Disable" : "Enable") + "</button></form> " +
3313
- "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\" style=\"display:inline;\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
3259
+ "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">" + (isActive ? "Disable" : "Enable") + "</button></form> " +
3260
+ "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\" class=\"form-inline\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
3314
3261
  "</td></tr>";
3315
3262
  }).join("");
3316
3263
 
@@ -3319,20 +3266,20 @@ function renderAdminWebhooks(opts) {
3319
3266
  : "<p class=\"empty\">No webhook endpoints yet.</p>";
3320
3267
 
3321
3268
  var eventChecks = known.map(function (ev) {
3322
- return "<label style=\"display:block; margin:.3rem 0;\"><input type=\"checkbox\" name=\"evt_" + _htmlEscape(ev) + "\"> <code>" + _htmlEscape(ev) + "</code></label>";
3269
+ return "<label class=\"kv\"><input type=\"checkbox\" name=\"evt_" + _htmlEscape(ev) + "\"> <code>" + _htmlEscape(ev) + "</code></label>";
3323
3270
  }).join("");
3324
3271
 
3325
3272
  var createForm =
3326
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:40rem;\">" +
3327
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Add an endpoint</h3>" +
3273
+ "<div class=\"panel mt mw-40\">" +
3274
+ "<h3 class=\"subhead\">Add an endpoint</h3>" +
3328
3275
  "<p class=\"meta\">Deliveries are signed (HMAC-SHA3-512); the signing secret is shown once, right after you create the endpoint. Only https:// URLs are accepted.</p>" +
3329
3276
  "<form method=\"post\" action=\"/admin/webhooks\">" +
3330
3277
  _setupField("Endpoint URL", "url", "", "url", "Where deliveries are POSTed (https:// only).", " maxlength=\"2048\" required") +
3331
- "<fieldset style=\"border:1px solid var(--hair); border-radius:.5rem; padding:.75rem 1rem; margin:1rem 0;\">" +
3332
- "<legend style=\"padding:0 .4rem; font-size:.85rem;\">Events</legend>" +
3333
- "<label style=\"display:block; margin:.3rem 0;\"><input type=\"checkbox\" name=\"events_all\"> <strong>All events (*)</strong></label>" +
3278
+ "<fieldset class=\"box\">" +
3279
+ "<legend class=\"legend-sm\">Events</legend>" +
3280
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"events_all\"> <strong>All events (*)</strong></label>" +
3334
3281
  eventChecks +
3335
- "<small style=\"color:var(--mute);\">Pick specific events, or check “All events” to subscribe to everything.</small>" +
3282
+ "<small class=\"u-mute\">Pick specific events, or check “All events” to subscribe to everything.</small>" +
3336
3283
  "</fieldset>" +
3337
3284
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create endpoint</button></div>" +
3338
3285
  "</form>" +
@@ -3346,13 +3293,13 @@ function renderAdminWebhookSecret(opts) {
3346
3293
  opts = opts || {};
3347
3294
  var e = opts.endpoint || {};
3348
3295
  var body =
3349
- "<section style=\"max-width:42rem;\">" +
3296
+ "<section class=\"mw-42\">" +
3350
3297
  "<h2>Endpoint created</h2>" +
3351
3298
  "<div class=\"banner banner--ok\">Copy the signing secret now — it is shown once and cannot be retrieved again.</div>" +
3352
3299
  "<div class=\"panel\">" +
3353
3300
  "<p class=\"meta\">Endpoint</p><p><code class=\"order-id\">" + _htmlEscape(e.url || "") + "</code></p>" +
3354
3301
  "<p class=\"meta\">Signing secret (HMAC-SHA3-512, key id <code>v1</code>)</p>" +
3355
- "<pre style=\"white-space:pre-wrap; word-break:break-all; background:var(--bg); border:1px solid var(--hair); border-radius:.5rem; padding:.75rem;\"><code>" + _htmlEscape(e.secret || "") + "</code></pre>" +
3302
+ "<pre class=\"code-block\"><code>" + _htmlEscape(e.secret || "") + "</code></pre>" +
3356
3303
  "<p class=\"meta\">Verify each delivery's signature with this secret using your framework's webhook verifier.</p>" +
3357
3304
  "</div>" +
3358
3305
  "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/webhooks\">Done</a></div>" +
@@ -3379,7 +3326,7 @@ function renderAdminWebhookDeliveries(opts) {
3379
3326
  : "<span class=\"status-pill " + (d.last_error ? "refunded" : "pending") + "\">" + (d.last_error ? "failed" : "pending") + "</span>";
3380
3327
  var code = d.last_status != null ? _htmlEscape(String(d.last_status)) : "—";
3381
3328
  var retry = ok ? "<span class=\"meta\">—</span>"
3382
- : "<form method=\"post\" action=\"/admin/webhooks/deliveries/" + _htmlEscape(d.id) + "/retry\" style=\"display:inline;\"><button class=\"btn btn--ghost\" type=\"submit\">Retry</button></form>";
3329
+ : "<form method=\"post\" action=\"/admin/webhooks/deliveries/" + _htmlEscape(d.id) + "/retry\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">Retry</button></form>";
3383
3330
  return "<tr>" +
3384
3331
  "<td>" + _htmlEscape(d.event_type) + "</td>" +
3385
3332
  "<td>" + statusCell + "</td>" +
@@ -3429,7 +3376,7 @@ function renderAdminCollections(opts) {
3429
3376
  "<td><div class=\"actions-row\">" +
3430
3377
  "<a class=\"btn btn--ghost\" href=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "\">Manage</a>" +
3431
3378
  (isArchived ? "" :
3432
- "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "/archive\" style=\"display:inline;\">" +
3379
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "/archive\" class=\"form-inline\">" +
3433
3380
  "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
3434
3381
  "</div></td>" +
3435
3382
  "</tr>";
@@ -3451,8 +3398,8 @@ function renderAdminCollections(opts) {
3451
3398
  }).join("");
3452
3399
 
3453
3400
  var createForm =
3454
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:40rem;\">" +
3455
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Create a collection</h3>" +
3401
+ "<div class=\"panel mt mw-40\">" +
3402
+ "<h3 class=\"subhead\">Create a collection</h3>" +
3456
3403
  "<p class=\"meta\">Manual collections are handpicked; smart collections match products by a rule set. Slug is the storefront URL (/collections/&lt;slug&gt;).</p>" +
3457
3404
  "<form method=\"post\" action=\"/admin/collections\">" +
3458
3405
  "<label class=\"form-field\"><span>Type</span><select name=\"type\">" +
@@ -3462,14 +3409,14 @@ function renderAdminCollections(opts) {
3462
3409
  _setupField("Title", "title", "", "text", "", " maxlength=\"500\" required") +
3463
3410
  _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated.", " maxlength=\"200\" required") +
3464
3411
  _setupField("Description (optional)", "description", "", "text", "", " maxlength=\"2000\"") +
3465
- "<fieldset style=\"border:1px solid var(--hair); border-radius:.5rem; padding:.75rem 1rem; margin:1rem 0;\">" +
3466
- "<legend style=\"padding:0 .4rem; font-size:.85rem;\">Smart rule (used only for smart collections)</legend>" +
3412
+ "<fieldset class=\"box\">" +
3413
+ "<legend class=\"legend-sm\">Smart rule (used only for smart collections)</legend>" +
3467
3414
  "<div class=\"actions-row\">" +
3468
3415
  "<select name=\"rule_field\"><option value=\"\">field…</option>" + fieldOpts + "</select>" +
3469
3416
  "<select name=\"rule_op\"><option value=\"\">op…</option>" + opOpts + "</select>" +
3470
3417
  "<input type=\"text\" name=\"rule_value\" placeholder=\"value (lists + between: comma-separated)\" maxlength=\"500\">" +
3471
3418
  "</div>" +
3472
- "<small style=\"color:var(--mute);\">Numeric fields (price_minor, inventory_count, created_at) compare as integers. Add more rules after creating.</small>" +
3419
+ "<small class=\"u-mute\">Numeric fields (price_minor, inventory_count, created_at) compare as integers. Add more rules after creating.</small>" +
3473
3420
  "</fieldset>" +
3474
3421
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create collection</button></div>" +
3475
3422
  "</form>" +
@@ -3501,8 +3448,8 @@ function renderAdminCollection(opts) {
3501
3448
  }).join("");
3502
3449
 
3503
3450
  var editForm =
3504
- "<div class=\"panel\" style=\"max-width:40rem;\">" +
3505
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Details</h3>" +
3451
+ "<div class=\"panel mw-40\">" +
3452
+ "<h3 class=\"subhead\">Details</h3>" +
3506
3453
  "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/edit\">" +
3507
3454
  _setupField("Title", "title", col.title, "text", "", " maxlength=\"500\" required") +
3508
3455
  _setupField("Description", "description", col.description || "", "text", "", " maxlength=\"2000\"") +
@@ -3524,17 +3471,17 @@ function renderAdminCollection(opts) {
3524
3471
  var opOpts = "<option value=\"\">op…</option>" + ops.map(function (o) {
3525
3472
  return "<option value=\"" + _htmlEscape(o) + "\"" + (o === rule.op ? " selected" : "") + ">" + _htmlEscape(o) + "</option>";
3526
3473
  }).join("");
3527
- return "<div class=\"actions-row\" style=\"margin:.4rem 0;\">" +
3474
+ return "<div class=\"actions-row m-04\">" +
3528
3475
  "<select name=\"rule_field\">" + fieldOpts + "</select>" +
3529
3476
  "<select name=\"rule_op\">" + opOpts + "</select>" +
3530
3477
  "<input type=\"text\" name=\"rule_value\" value=\"" + _htmlEscape(String(rv)) + "\" placeholder=\"value\" maxlength=\"500\">" +
3531
3478
  "</div>";
3532
3479
  }).join("");
3533
3480
  editForm +=
3534
- "<fieldset style=\"border:1px solid var(--hair); border-radius:.5rem; padding:.75rem 1rem; margin:1rem 0;\">" +
3535
- "<legend style=\"padding:0 .4rem; font-size:.85rem;\">Rules (all must match)</legend>" +
3481
+ "<fieldset class=\"box\">" +
3482
+ "<legend class=\"legend-sm\">Rules (all must match)</legend>" +
3536
3483
  ruleRows +
3537
- "<small style=\"color:var(--mute);\">Leave a row blank to drop it. Lists (in / not_in) + between use comma-separated values.</small>" +
3484
+ "<small class=\"u-mute\">Leave a row blank to drop it. Lists (in / not_in) + between use comma-separated values.</small>" +
3538
3485
  "</fieldset>";
3539
3486
  }
3540
3487
  editForm += "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save</button></div></form></div>";
@@ -3546,7 +3493,7 @@ function renderAdminCollection(opts) {
3546
3493
  return "<tr>" +
3547
3494
  "<td class=\"num\">" + _htmlEscape(String(m.position)) + "</td>" +
3548
3495
  "<td><code class=\"order-id\">" + _htmlEscape(m.product_id) + "</code></td>" +
3549
- "<td><form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/remove\" style=\"display:inline;\">" +
3496
+ "<td><form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/remove\" class=\"form-inline\">" +
3550
3497
  "<input type=\"hidden\" name=\"product_id\" value=\"" + _htmlEscape(m.product_id) + "\">" +
3551
3498
  "<button class=\"btn btn--danger\" type=\"submit\">Remove</button></form></td>" +
3552
3499
  "</tr>";
@@ -3560,8 +3507,8 @@ function renderAdminCollection(opts) {
3560
3507
  // client JS — the primitive normalises positions to 0..N-1.
3561
3508
  var currentIds = members.map(function (m) { return m.product_id; }).join(",");
3562
3509
  var reorderForm = members.length > 1
3563
- ? "<div class=\"panel\" style=\"margin-top:1rem; max-width:40rem;\">" +
3564
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Reorder members</h3>" +
3510
+ ? "<div class=\"panel mt-1 mw-40\">" +
3511
+ "<h3 class=\"subhead\">Reorder members</h3>" +
3565
3512
  "<p class=\"meta\">Rewrite the comma-separated id list into the order you want. Must list every current member.</p>" +
3566
3513
  "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/reorder\">" +
3567
3514
  "<label class=\"form-field\"><span>Ordered product ids</span>" +
@@ -3572,15 +3519,15 @@ function renderAdminCollection(opts) {
3572
3519
  : "";
3573
3520
 
3574
3521
  var addForm =
3575
- "<div class=\"panel\" style=\"margin-top:1rem; max-width:40rem;\">" +
3576
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Add a member</h3>" +
3522
+ "<div class=\"panel mt-1 mw-40\">" +
3523
+ "<h3 class=\"subhead\">Add a member</h3>" +
3577
3524
  "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/add\">" +
3578
3525
  _setupField("Product id", "product_id", "", "text", "The catalog product's UUID.", " maxlength=\"64\" required") +
3579
3526
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add product</button></div>" +
3580
3527
  "</form>" +
3581
3528
  "</div>";
3582
3529
 
3583
- detailBody = "<section style=\"margin-top:1.5rem;\"><h3 style=\"font-size:1.05rem;\">Members</h3>" +
3530
+ detailBody = "<section class=\"mt\"><h3 class=\"fs-105\">Members</h3>" +
3584
3531
  memberTable + reorderForm + addForm + "</section>";
3585
3532
  } else {
3586
3533
  var preview = opts.preview || [];
@@ -3594,7 +3541,7 @@ function renderAdminCollection(opts) {
3594
3541
  var previewTable = preview.length
3595
3542
  ? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Status</th></tr></thead><tbody>" + previewCards + "</tbody></table></div>"
3596
3543
  : "<p class=\"empty\">No products match these rules yet.</p>";
3597
- detailBody = "<section style=\"margin-top:1.5rem;\"><h3 style=\"font-size:1.05rem;\">Matched products (live preview)</h3>" +
3544
+ detailBody = "<section class=\"mt\"><h3 class=\"fs-105\">Matched products (live preview)</h3>" +
3598
3545
  "<p class=\"meta\">The first " + _htmlEscape(String(preview.length)) + " products the rules match right now.</p>" +
3599
3546
  previewTable + "</section>";
3600
3547
  }
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": "0.1.25",
3
+ "assets": {
4
+ "css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
5
+ "css/main.css": "sha384-qBY97FWZDGOploZRppwrDi0A/+R4R3iCFtyS1dfrAlqHNEPluPmY0R3HB9cJntyV",
6
+ "js/passkey-login.js": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
7
+ "js/passkey-register.js": "sha384-RaSkJbTpc6ClfPy5QyaY96doIqBqa9CLeIB1CzWnL05pSSJEA9IrIK0S7V7igRxY"
8
+ }
9
+ }
package/lib/storefront.js CHANGED
@@ -203,13 +203,21 @@ var LAYOUT =
203
203
  // each render call to override; absent that, every storefront page
204
204
  // references the shipped default theme — so a fresh install renders
205
205
  // styled out of the box without any wiring.
206
+ // Asset integrity + version manifest — sha384 digests + the release
207
+ // version, baked at build time (scripts/generate-asset-manifest.js) and
208
+ // committed. Read here instead of hashing the files at render time so the
209
+ // integrity attribute and the cache-buster are present in every runtime,
210
+ // including the container image and the edge Worker, neither of which
211
+ // ships the theme asset files to hash live. See lib/asset-manifest.json.
212
+ var _assetManifest = require("./asset-manifest.json");
213
+
206
214
  // The default theme stylesheet ships from R2 at this path. The
207
215
  // `?v=` query is a build-time cache-buster — operator uploads to R2
208
216
  // rewrite the bytes at the same path, and without the version
209
217
  // param browsers happily serve the previous cached CSS for the
210
- // five-minute default TTL. Bumping this constant on each release
211
- // forces every active session to re-fetch.
212
- var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" + require("../package.json").version;
218
+ // five-minute default TTL. The manifest version bumps each release,
219
+ // forcing every active session to re-fetch.
220
+ var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" + _assetManifest.version;
213
221
 
214
222
  // Footer copyright year — resolved once at module load. It's a near-static
215
223
  // value (changes once a year); a `new Date()` allocation on every page
@@ -222,30 +230,17 @@ var _COPYRIGHT_YEAR = String(new Date().getUTCFullYear());
222
230
  // 'self'` CSP blocks inline <script>, so an inline island silently fails
223
231
  // in production. `'self'` allows these /assets/ files; the R2 asset sync
224
232
  // (npm run sync-assets) uploads them alongside the stylesheets.
225
- var _ASSET_JS_VERSION = require("../package.json").version;
226
- var _assetFs = require("node:fs"); // allow:non-shop-require — one sync read of a bundled in-repo asset to compute its SRI hash via b.crypto.sri (the primitive needs the bytes); no remote/write, and b.staticServe.integrity is async so can't run at module load
227
-
228
- // Subresource Integrity for the static assets. `b.crypto.sri` (SHA-384,
229
- // W3C SRI 1.0) hashes the exact bytes the asset ships with; the browser
230
- // refuses to run/apply the resource if the served bytes don't match — a
231
- // defense against an R2/edge compromise or on-path injection. Computed
232
- // once per asset from the in-repo file (the same bytes the deploy's asset
233
- // sync uploads to R2), cached, and emitted as the `integrity` attribute.
234
- // Same-origin, so no `crossorigin` is required for the check to run.
235
- var _SRI_CACHE = {};
233
+ var _ASSET_JS_VERSION = _assetManifest.version;
234
+
235
+ // Subresource Integrity for the static assets — the browser refuses to
236
+ // run/apply a resource whose served bytes don't match (defense against an
237
+ // R2/edge compromise or on-path injection). The sha384 digest (W3C SRI
238
+ // 1.0) is read from the build-time manifest keyed by the path under the
239
+ // default theme's asset root; an absent key (a custom operator theme,
240
+ // whose bytes aren't ours to hash) yields no attribute. Same-origin, so
241
+ // no `crossorigin` is required for the check to run.
236
242
  function _assetSri(relUnderThemeAssets) {
237
- if (Object.prototype.hasOwnProperty.call(_SRI_CACHE, relUnderThemeAssets)) {
238
- return _SRI_CACHE[relUnderThemeAssets];
239
- }
240
- var sri = null;
241
- try {
242
- // Forward slashes resolve cross-platform for fs reads; avoids a
243
- // node:path require for one join.
244
- var p = __dirname + "/../themes/default/assets/" + relUnderThemeAssets;
245
- sri = b.crypto.sri(_assetFs.readFileSync(p), { algorithm: "sha384" });
246
- } catch (_e) { sri = null; } // asset absent (e.g. unit-test cwd) → omit integrity, don't crash render
247
- _SRI_CACHE[relUnderThemeAssets] = sri;
248
- return sri;
243
+ return _assetManifest.assets[relUnderThemeAssets] || null;
249
244
  }
250
245
  function _islandScript(name) {
251
246
  var sri = _assetSri("js/" + name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.22",
3
+ "version": "0.1.25",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {