# Copy work — surface map and safe-change protocol **Written 2026-10-17.** Purpose: change customer-facing words without touching the pieces that take money. Read this before editing anything; it is the operational companion to [`CONTENT-LAYER.md`](CONTENT-LAYER.md) (the binding content contract) and [`DEPLOY-SUBDOMAIN.md`](DEPLOY-SUBDOMAIN.md) (deployment). --- ## 1. What is live, and where it comes from | Thing | Value | |---|---| | Public URL | **https://shop.metatronmind.com** (root, `BASE_PATH=""`) | | Legacy URL | `https://metatronmind.com/store` still answers (same app) | | Order desk | `https://shop.metatronmind.com/admin` (password-gated) | | App on the box | `~/` on `vps` (``), systemd user unit `tshirt`, `localhost:8787` | | Local source of truth | `~//T-Shirt/app/` | | **Data vault** | `/settings.json` **on the box**, outside the app tree | | Ingress | `/etc/cloudflared/config.yml` — hostname rule for `shop`; **never edit for copy work** | Current synchronisation state (verified 2026-10-17): `server.mjs`, `index.html`, `store.js`, `cart.js`, `admin.html`, `admin.js` are **byte-identical local vs deployed** (sha256 matched). The workspace is the deploy source, and it is clean. **No git in this workspace and none should be initialised.** Backups are the version control here: `.baseline-*/` locally, `~/settings.json.pre-*` and `~/storefront-backup-*.tgz` on the box. --- ## 2. The boundary — three layers, three change paths | Layer | Lives in | Changed by | Deploy needed? | |---|---|---|---| | **Structure** (layout, markup skeletons, classes) | `app/public/*.html`, `assets/*.css`, `assets/store.js` | code edit + deploy | **yes** | | **Store-level words** (hero, ticker, proof, footer, About, SEO, facility lines) | vault `settings.storefront` | **Admin → Storefront** | **no** — instant, survives redeploy | | **Product words** (headline, tagline, badge, bullets, description, specs, care, shipping note, CTA, tags) | vault `settings.listings` | **Admin → Published → Copy** | **no** | | Shipping labels/ETA/notes | vault `settings.shippingLabels` | Admin → Published → Shipping methods | **no** | The rule: **words are data; layout is code.** Prefer the vault path for every string it covers. Only fall back to a code edit when the string has no content-layer mount. --- ## 3. Surfaces the customer actually reads ### 3a. Vault-driven (no deploy) — Admin → Storefront Nine independently saved groups; each falls back to the shipped literal when blank, so an empty vault renders today's shop exactly. Server caps **truncate, never reject** — watch the counter. | Group | Fields | Renders on | |---|---|---| | `identity` | name (90), tagline (200) + `taglineEnabled`, description (300 — **stored but rendered nowhere**, see trap below) | ``/wordmark from name; tagline only when enabled | | `hero` | `enabled`, eyebrow (90), headline (160, `\n`→`<br>`), sub (400) | band above the grid — **off by default, no shipped hero exists** | | `grid` | lede (400) + `set` | grid head under `[ ARCHIVE ]` | | `ticker` | up to 8 lines × 160 | marquee under nav | | `proof` | `enabled`, eyebrow, headline (160), sub (400), up to 8 marks `{value}`, note (400) | `[ PROOF ]` band. ⚠ claim must remain verifiable | | `footer` | truthLine (400), up to 8 facts `{label,value}` | footer | | `facility` | `intlText`, `usText`, `ddpText`, per-listing `byLid` overrides | shipping/origin lines in cart + listing | | `about` | `enabled`, title (160), intro (400), up to 8 sections `{heading 80, body 2000}` | `/about` — **currently zero sections: page renders the shipped head + data position** | | `seo` | `titleTemplate` (`%s` required), `defaultTitle` (90), `defaultDescription` (300) | document title + the real `<meta name="description">` | ⚠ **Trap: two fields look like the meta description, only one is.** The Identity card's "Meta description" (`identity.description`, cap 300) is **stored and rendered nowhere** in the current storefront code; the live `<meta name="description">` comes from the **SEO card's `defaultDescription`**. The operator set both to the same string, so it currently looks correct. Edit the **SEO card**, not Identity, to change search/social copy. Live state now: only `identity` and `seo` are stored. Everything else renders shipped defaults. ### 3b. Vault-driven per product (no deploy) — Admin → Published → Copy 12 published listings, keyed by listing id `{productId}__{designId}`. Copy keyed by listing inherits from another listing of the same (product, piece) when blank, so a recolour keeps words. Current copy state (live `/api/store`, 2026-10-17): 12 listings but only **8 unique headlines** ("Shake Well" ×2, "Give It To Me Raw" ×2, "Anchored In" ×3 — identical tiles in the grid). All 12 have a headline, tagline and description; **5 have one bullet, 0 have spec rows, 0 have a badge; 3 have no category tags** (`RT0063-C001-V5` rows — untagged listings cannot be filtered to). `tags` build the filter chips, so the vocabulary is the operator's: lowercased on save, max 8, max 24 chars. ### 3c. Static page copy (code edit → deploy) | File | Copy that lives there | |---|---| | `app/public/index.html` | `<title>`, meta description **fallback**, nav labels, no-JS block, footer fallback fine print (`foot-fine`), empty/404 fallbacks | | `app/public/about.html` | no-JS head, shipped About head + "What you are buying" stats + data-position prose, "this page is not published" state | | `app/public/checkout.html` | `[ STRIPE ]` disclosure, order/payment headings, **the footnote under the payment form** | | `app/public/checkout-return.html` | thank-you page: headline, "What happens now", footer | | `app/public/assets/store.js` | runtime strings: grid lede fallback, tile fine print, buy hints, empty/unreachable states, SEO fallback object | | `app/public/assets/cart.js` | drawer/page copy, empty-cart text, toasts, checkout gate labels (`GATE_ON`/`GATE_OFF`) | | `app/public/assets/checkout.js` | payment-form and error messages | | `app/public/assets/about.js` | About frame strings + "not published"/"could not be loaded" states | | `app/public/admin.html`, `assets/admin.js` | operator-facing copy only — not customer copy | ### 3d. Transactional email copy (code edit → deploy, server restart) Both templates are literals in `app/server.mjs`: `orderConfirmationText()` (~line 1605) — sent from the Stripe webhook after the Tapstitch draft; `shippingNotificationText()` (~line 1548) — sent when tracking first appears. Subjects: `Order <ref> confirmed`, `Order <ref> has shipped`. SMTP is configured (`<email>`), so edits here reach real customers. ### 3e. Frozen review artefacts — do not touch `preview.html`, `preview-data.js`, `designsystem.html` are frozen design-review snapshots, not customer pages. Editing them would falsify a design record. --- ## 4. ✅ RESOLVED 2026-10-17 — the false "checkout is not live yet" copy **This entire section was fixed and deployed on 2026-10-17** (see the change log at the bottom). It is kept here as history: the rule it records still matters — *customer-facing state copy is not latched to a comment, it is latched to the state it describes.* The exception left in place is `GATE_OFF` in `cart.js`, which only renders if Stripe is ever unconfigured (then it is true). Original finding (superseded): Checkout is **live** (`stripeReady: true`, real paid sessions recorded) but the shop still says it is not. Live-verified 2026-10-17: | Where | String | Gated? | |---|---|---| | `/checkout`, under the payment form | "checkout is not live yet — this is a test build and no real payment can be taken." | **NO — always shown** | | Home footer fine print | "Checkout is not live yet — nothing is charged and no payment is taken." | no | | Product card fine print | "…Duties and taxes included · checkout is not live yet." | no | | Product detail buy hint | "Adding to a cart only — checkout is not live yet." (×2 branches) | no | | Add-to-cart toasts | "Added to cart — N items. Checkout is not live yet." (×2) | no | | Cart drawer / cart page | "Checkout is not live yet, so nothing can be ordered or charged." | no | | `<meta name="description">` fallback + `store.js` `defaultDescription` | "…Checkout is not live yet." | fallback only (live SEO is overridden to "Custom Garments Direct to You.") | | `index.html` no-JS block | "Checkout is not live yet, so nothing can be charged." | no-JS only | The checkout **button** is correctly state-driven (`GATE_ON`/`GATE_OFF` in `cart.js`, switched by `stripeReady`) — those two labels are not bugs. Count today: 17 occurrences of "not live yet" across `index.html` (3), `store.js` (5), `cart.js` (9, incl. 1 comment). `checkout.html` adds 1. `STRIPE_INTEGRATION_TODO.md` §6 already flags this as go-live checklist item 2; it was deferred because the copy was true then. It is false now. --- ## 5. Orders — context for copy claims Recorded in vault `settings.stripeOrders` (7 paid sessions). Status comes from Tapstitch via the signed webhook plus a 15-minute poller (`reconcileTapstitchOrder`), **not** from what the customer did. | Order | Paid | Tapstitch status | Note | |---|---|---|---| | <order-1> | $24.76 | `removed_at_printer` | terminal — order no longer exists at Tapstitch | | <order-2> | $32.76 | `removed_at_printer` | terminal | | <order-3> | $24.76 | `removed_at_printer` | terminal; email-path test order | | <order-4> | $67.46 | `removed_at_printer` | terminal | | <order-5> | $24.76 | `removed_at_printer` | terminal | | <order-6> | $25.95 | **`in_review`** | live order — awaiting production/shipping | | <order-7> | $58.55 | **`in_review`** | live order — paid 2026-10-17 03:18Z | - `autoConfirmHours: 24` on the box (drafts auto-confirm 24 h after the draft existed; `0` = never). - Confirmation email recorded sent for 5 of 7 (the two oldest predate SMTP; the rest were sent). No tracking email yet — none of the orders has reached a shipped state. The poller marks a disappeared order `removed_at_printer` and stops polling it (`[poll] … order no longer exists at Tapstitch — marked terminal`). - **There is no Orders tab in `/admin`.** `stripeOrders` is written by the webhook and read by the poller/confirmation page; the operator currently reads order state in the Tapstitch portal or the vault. Copy should not promise a customer-facing order-lookup the shop does not have. - Do not put order counts, "selling fast", ETAs the provider has not verified, or similar claims in copy — `SCOPE.md` forbids invented urgency and the shipping labels already avoid unverified ETAs (only the Standard tier has "Please Allow 2 Weeks For Print + Delivery"). --- ## 6. Safe-change protocol ### Rules 1. **Never edit files on the box directly.** Edit in the workspace, deploy with rsync. 2. **Never hand-edit the vault while `tshirt` is running.** The server holds settings in memory and `writeSettings()` on the next webhook/poll/save will overwrite the file. Use Admin → Storefront (the server writes memory + disk + `.bak` together), or stop the unit, edit, start. 3. **Copy changes must not alter** `server.mjs` routing, the CSP blocks, the `[ PROOF ]` claim (it is verified: one origin, self), pricing, or any order/variant/design identifier. 4. **No new origins, fonts, scripts or trackers** on customer pages. The claim "every byte on this page is ours" is audited in `docs/storefront/THIRD-PARTY-AUDIT.md`; `/checkout` is the one relaxed-CSP page (Stripe only, disclosed). 5. **Never render `styleCode`/variant ids/`design_id` to a customer.** `prettyLines` is the human form; `lines` is machine-only. 6. One writer per file if more than one person is editing (`SCOPE.md`, `CONTENT-LAYER.md` §6). ### Pre-flight (once per copy session) ```bash cd ~/<workspace>/T-Shirt # 1. prove the workspace still matches production before editing for f in server.mjs public/index.html public/assets/store.js public/assets/cart.js; do printf '%-28s local %s remote %s\n' "$f" \ "$(shasum -a 256 app/$f | cut -c1-12)" \ "$(ssh vps "sha256sum ~/<app-dir>/$f" | cut -c1-12)" done # 2. snapshot what you are about to touch (local + remote) mkdir -p .baseline-copy-$(date +%F) cp app/public/index.html app/public/assets/{store.js,cart.js,checkout.js} .baseline-copy-$(date +%F)/ ssh vps 'cd ~/<app-dir> && tar czf ~/app-backup-$(date +%F-%H%M).tgz server.mjs public' ssh vps 'cp <vault-dir>/settings.json <vault-dir>/settings.json.pre-copy-$(date -u +%Y%m%dT%H%M%SZ)' ``` ### A. Store-level / product copy (preferred, no deploy) 1. Log in at **https://shop.metatronmind.com/admin** → **Storefront** tab (or **Published → Copy**). 2. Each card saves on its own; the counter turns amber at the cap and red past it. Empty = shipped fallback. 3. Reload the storefront; the change is live on next page load. The vault write is atomic with a `.bak` generation, so there is no partial-write risk. ### B. Bulk/scripted vault change (only when the UI is impractical) ```bash ssh vps 'sudo -u ubuntu bash -s' # already the user; example shape # stop → back up → merge JSON with a script → start ssh vps 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user stop tshirt' # (edit <vault-dir>/settings.json with node; keep 0600) ssh vps 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user start tshirt' curl -s https://shop.metatronmind.com/api/store | head -c 200 ``` Downtime is seconds. Never run this while the service is up. ### C. Static / email copy (code edit → deploy) ```bash # 1. edit locally, then preview against a clone of the live vault (read-only copy) rsync -a vps:<vault-dir>/ /tmp/tshirt-preview-data/ # settings, art, cache — 300 MB, one-off cd ~/<workspace>/T-Shirt PORT=8899 BASE_PATH="" DATA_DIR=/tmp/tshirt-preview-data node app/server.mjs # → http://localhost:8899/ /about /cart /checkout (compare to live beside it) # 2. deploy (vault is outside the tree; --delete cannot touch it) ssh vps 'cd ~/<app-dir> && tar czf ~/app-backup-$(date +%F-%H%M).tgz server.mjs public' rsync -av --delete ~/<workspace>/T-Shirt/app/ vps:<app-dir>/ ssh vps 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user restart tshirt' ssh vps 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active tshirt' ``` ### D. Verification after any deploy ```bash # routes all 200 (/api/settings must stay 401) for p in / /about /cart /checkout /admin /api/store /api/settings /assets/store.js /assets/cart.js \ /assets/checkout.js /assets/storefront.css; do printf '%-28s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://shop.metatronmind.com$p)" done # the payload still carries everything the site needs curl -s https://shop.metatronmind.com/api/store | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const j=JSON.parse(d);console.log("published",j.published,"| products",j.products.length,"| stripeReady",j.stripeReady,"| content",Object.keys(j.content).join(","))})' # no secrets in the public payload curl -s https://shop.metatronmind.com/api/store | grep -Eic 'apiToken|stripeSecret|adminPasswordHash|sessionSecret' # expect 0 # customer-page third-party origins (expect only Stripe on /checkout, none elsewhere) grep -rohE 'https?://[a-zA-Z0-9.-]+' app/public/index.html app/public/about.html \ app/public/assets/store.js app/public/assets/cart.js | sort -u # email copy changed? send one test through the same template on the box, not a real order # by eye: home / detail / cart / checkout / about at 390 and 1440, one h1 per view ``` Also re-run after copy changes that touch the proof band or footer: the `[ PROOF ]` claim is a proposition, not a slogan (`THIRD-PARTY-AUDIT.md`). ### E. Rollback | What changed | Rollback | |---|---| | Static code | `ssh vps 'cd ~/<app-dir> && rm -rf public server.mjs && tar xzf ~/app-backup-<ts>.tgz'` then restart; or re-rsync from `.baseline-copy-<date>/` | | Vault content | restore `settings.json.pre-copy-<ts>` **with the service stopped**, then start | | Both | the vault is independent of the app tree — code rollback cannot roll content back, and vice versa | --- ## 7. Open questions before copy work starts 1. **Scope** — store-level words, per-product words, checkout/cart wording, About page, emails, or all? The "not live yet" corrections (§4) should be part of any copy pass. 2. **Access** — the admin password is the operator's; it is not in this repo (`CREDENTIALS.md` names locations only). To edit via the UI I need either the operator to paste changes into Admin → Storefront, or the password for the order desk (then the vault-write is done by the server itself). Bulk edits can be scripted with the service stopped instead. 3. **Voice inputs** — `SCOPE.md` §"House voice" lists the sanctioned lines. Anything new should be written into that list so the shipped defaults and the operator copy stay distinguishable. 4. **Truth constraints** — no invented ETAs, stock, reviews, urgency; shipping is `5–15 working days` from Guangzhou (DDP included) unless the operator has a verified US window. --- ## 8. Change log ### 2026-10-17 — copy pass 1 (deployed) Deployed to `shop.metatronmind.com` from this workspace; remote and local hashes match. Backups: `~/app-backup-2026-10-17-0413.tgz` and `-0417.tgz` on the box, `<vault-dir>/settings.json.pre-copy-20261017T041748Z`, local `.baseline-copy-2026-10-17/`. | Change | Where | |---|---| | Removed every "checkout is not live yet" claim (17 occurrences + the checkout-page "test build, no real payment" footnote) | `index.html`, `store.js`, `cart.js`, `checkout.html` | | Checkout button markup ships as plain `Checkout`; the `stripeReady` gate still swaps wording | `cart.js` | | SIZES `XS–3XL · unisex & women` → `XS–3XL · men & women` | `server.mjs` defaults, `store.js` fallbacks, `index.html`, `about.html`, admin placeholders | | TRANSIT `5–15 working days` → `Up to 21 days` | same files + `server.mjs` facility line and shipping-label seed; `about.html` | | DUTIES `Included (DDP) — nothing to pay at the door` → `Included` | footer/about facts only; the under-buy-box DDP sentence is unchanged | | Proof mark `0 third-party requests` → `1 third-party request` (all pages) | defaults + static fallbacks | | Proof sub `…Nothing sold about you.` → `…We don't want your data.` | defaults + static fallbacks | | Retired the challenge note ("Check for yourself — open your network tab…") | defaults `note: ""`, static HTML, About `data-ship-note` + rendered `proof-fine`; `about.js` now tolerates a missing attr | | Featured block fine print reduced to `From $57.16` | `store.js` `heroHtml()` | | Dropped the duplicate-headline `WEIGHT — … GSM / … oz` row on product cards (plates are now uniform; the plate rail still carries GSM/oz on every card) | `store.js` `tileHtml()` (removed the now-unused `duplicateTitles`/`titleKey`/`dupes` machinery) | | Product detail buy hint → `Ships direct to you`; US-facility sentence dropped (`usText: ""`, blank renders nothing) | `store.js` `renderDetail()` / `transitHtml()`, `server.mjs` defaults | | Operator-facing captures of the shipped copy kept in step | `admin.html` placeholders, `admin.js` shipping-label seeds + proof-mark placeholder | **Copy pass 1b (2026-10-17, deployed).** Grid-head stat 4 changed from `SHIPS FROM / GUANGZHOU` to **`SHIPS / DIRECT TO YOU`** (`store.js` `gridHeadHtml()`; the `index.html` pre-fetch skeleton label was changed with it so the swap does not relabel the cell). The `fulfillmentCenter`-derived value was removed from the stat; the factual origin still appears on the About page and in the product-detail transit line. **Copy pass 1c (2026-10-17, deployed).** Footer credit changed from `Designed by <designer>.` to **`Designed by AI & Hu Mind Collaboration.`** on the storefront and About footers (`index.html`, `about.html`). The designer's individual credit is deliberately replaced: most artwork is hers, the store itself is the operator + AI collaboration. **Copy pass 1d (2026-10-17, deployed).** Empty cart state reduced to the heading + one sentence: `Your cart is empty.` / `Nothing here yet. Choose a size on any plate and it lands here.` The `[ CART ]` eyebrow and the "Back to the archive" button were removed from `emptyLinesHtml()` in `cart.js` (applies to both the drawer and the `/cart` page). The page keeps its own header and the nav's SHOP link, so nothing is left without a way out. **Copy pass 1e (2026-10-17, deployed).** Cart summary panel reduced to the four totals rows + `Checkout` + **`Payment by Stripe.`** (`cart.js` `summaryHtml()`). Removed: the delivery/address paragraph, the `shipOptionsHtml()` live-quote block, and the long below-button payment paragraph (drawer and page share the markup; the drawer's "Open the cart page" link is untouched). The `stripeReady` gate's ON note is now `Payment by Stripe.`; the OFF note still explains an actually unconfigured payment setup. Consequence: the SHIPPING row always reads `At checkout` — no live quote is rendered in the cart any more. **Still open / needs a decision** 1. **Two grid-head stat fields** — awaiting the operator's annotated screenshots (red marks read "Text", "Direct Shipping", "Sovereign Data"). Deliberately not touched. 2. **The proof headline now argues with the mark**: "Every byte on this page is ours." next to "1 third-party request" (`/checkout` is where that request lives, and it says so). Suggest either scoping the mark to `1 third-party request — checkout only` or rewording the headline. 3. **About prose vs proof mark**: the data section says "This page loads nothing but our own files: no third-party script…" while the band below declares 1 third-party request. Same decision as #2. 4. **Plate rail weights**: `FRONT · 260 GSM · 7.7 oz` still prints on every card. The inconsistent body row was what "some plates show weights, others don't" pointed at; confirm the rail stays. 5. **Cart ETA is vault data**: the live `settings.shippingLabels["1"].eta` still reads "Please Allow 2 Weeks For Print + Delivery" (not deployed code). Change it from Admin → Published → Shipping methods if the promise should be "up to 21 days".