Commit Graph

194 Commits

Author SHA1 Message Date
null ae966e8f56 fix(ui): header, Cisco logo and tap targets, found by rendering on devices
Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530
high findings, on pages that had passed Batch 11 and Batch 16.

#214 was fixed in v0.9.5 and released still broken. The check was a desktop
window sized to 768, which has a scrollbar, so the layout viewport was ~753px
and the md breakpoint the fix was about never engaged. At a true 768 the brand,
six nav links and the CTA want 787px against 736px of container, so no amount of
gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets
the Sheet menu, which is the better tablet experience regardless: 44px rows
instead of 17px ones, and submenus that do not depend on hover. The CTA takes
shrink-0 and whitespace-nowrap, so the next row that does not fit overflows
visibly instead of being squeezed past the edge while the total still measures
as fitting.

The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up
inside overflow-hidden and cut 13px off the trademark on / and 24px on /about.
Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as
the 8x8 logo beside it, unscaled and unclipped.

491 standalone links were 17 to 26px tall. .tap-target grows the hit box and
takes it back out of the layout, and every list using it moves to space-y-4:
at space-y-2 neighbouring boxes overlap and measure 33px while not being there.
Footer links also take block, so the row is the target. A 39x36 word is judged
as a compact target wanting 44px; a 200x36 row wants 32.

scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They
stay out of verify.sh: playwright is global here and the sweep needs a running
server, and a guard that cannot run on a clean clone is one that gets skipped.

After the fixes: zero blocking, zero high across all ten devices. Each defect
was then re-introduced and reported again (clipped x1, media_overflow x1,
past_viewport x19, touch_target x342).

closes #238
closes #239
closes #240
closes #241

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 07:09:32 -05:00
null 8e04201d68 chore(release): v0.9.7 2026-09-10 06:01:25 -05:00
null 798575576b ui: the site claimed 25 years of proven reliability, not 25 years of experience
Null's instruction, 2026-09-10. The number itself is confirmed and stays
everywhere it appears. What comes off is the claim wrapped around it in three
places, each of which asserted an outcome over that time rather than experience:

  index.html:12   meta description      "25+ years of proven reliability."
  index.html:15   og:description        "25+ years of proven reliability."
  Home.jsx:97     the home description  "...and networking. 25+ years."

All three now read "25+ years of industry experience", which is what the rest of
the site already said: the home trust chip ("25+ Years Experience"), the About
stat ("Communications and infrastructure experience"), the contact page ("25+
years of experience") and the footer.

Two nearby claims were left alone as different in kind, and are worth a second
look if the intent was broader: Contact.jsx:179 "8x8 Certified Partner with
proven expertise", which is about a partnership rather than a track record, and
About.jsx:83 "technology that works - proven, stable, and repeatable", which is
about the technology rather than the customers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 06:00:44 -05:00
null 7feb7174d3 docs: the two unsubstantiated claims are cleared, and the plan said otherwise
PROJECT_PLAN listed a fabricated-looking certification number and an unverified
"25+ years" as claims live on the site that nobody could substantiate. Neither
is true now. The number came off the site in May on the owner's instruction, and
the owner confirmed the 25+ years experience claim on 2026-09-10 (#110).

The habit that produced them is what the section now describes, since that is the
part still worth reading: the unattributed "99.999% uptime" line came off the
contact centre page the same week, on the owner's instruction, and his approved
copy sheets are now the standard for what a claim on a page has to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 05:58:31 -05:00
null 8cc9b4342f docs: log the day Levi's pages shipped, and the seven live defects found doing it
Dated entry per WORK_CYCLE step 6: what shipped, what the reviews found before
any code was written, what each guard now refuses, and what is left.

The short version of the second half: the secret scanner had never once run its
private-key pattern, React was discarding the prerendered DOM on every page, the
production sitemap carried no dates at all, every page preloaded the wrong image,
eleven pages shipped a run-on description, an odd link could blank a page, and
every Docker build context carried the operator's live credentials. All fixed,
each proven by breaking it first.

Next action and blockers are in the entry: the Google and Bing submissions need
account access this session did not have, and Levi's four vendor screenshots need
him.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 05:20:13 -05:00
null 6a455ae7e8 fix(build): tell a rewritten response from a page served by agent
The live audit reported "crawlers were served different bytes" for
/privacy-policy on qn.isnull.dev. That reads as the origin choosing what to
serve by user agent, which would be serious. It is not what is happening:
Cloudflare's email obfuscation rewrites the privacy address on that front door
with a token that changes on every response, so any two fetches differ.

The audit now asks the same agent twice before blaming the agents, and says
which of the two it found. A misleading finding is how a checker gets ignored.

Refs #228.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 05:18:01 -05:00
null 11992d8d95 chore(release): v0.9.6 2026-09-10 05:06:40 -05:00
null aff80334df feat(content): publish Levi's approved UC and Contact Center pages
The work this batch exists for. Levi Halford approved two copy sheets on
2026-08-28 and sent them the same day; they were never deployed. Closes #220,
#219 and #218.

What ships, verbatim from the sheets:

  /services/unified-communications   1,125 words, 10 sections,  7 FAQs
  /services/contact-center           2,499 words, 12 sections, 10 FAQs

Titles, H1s, hero subheadings and descriptions are the approved strings exactly,
including descriptions at 164 and 191 characters, which are emitted whole rather
than clamped. Each section opens with its direct answer, because that passage is
what a search result or an AI answer quotes. Every section carries a stable,
letter-first anchor, so an answer can deep-link to it. The FAQ is visible h3 and
p, with no FAQPage markup, as the sheets require.

The old copy goes: the unattributed "99.999% uptime reliability" the sheet named,
and both pages' short blurbs, which the approved sections now say at length.
idealFor stays, because the services index prints it as "Best fit".

Levi's two constraints are held and proven, not assumed. The footer is byte for
byte identical to the pre-batch build on all 19 pages. The Quick Info box is on
both pages, unchanged.

Verified against the built HTML, not the source: title, description and H1 match
the sheets exactly; all 22 approved H2s and all 17 FAQ questions are present; no
website-manager direction survived (checked against 20 phrases); no 99.999, no em
dash, no U+FFFD, no markdown. In a browser: no horizontal scroll, no broken
image, no console error at 320, 390, 768 and 1024; hydration clean on all 18
pages; all 24 section anchors deep-link correctly.

Docs corrected in the same commit, because each would have sent the next writer
the wrong way: REDESIGN_REVIEW's metrics lists now carry the owner's 2026-09-10
direction that a figure must be current, vendor-attributed and verified;
OVERHAUL_PLAN gains the long-form blueprint and stops recommending an accordion
for an FAQ; README stops claiming a server-rendered privacy fallback that never
existed, and says what hydration actually does now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 05:05:21 -05:00
null cd0a517d4a feat(seo): the industry and support pages now point at the two service pages
Both approved sheets ask for it: Unified Communications section 15 and Contact
Center section 18 each list the pages that should link back to them. Home, the
services index, the header and the footer already did. Managed Support and the
four industry pages did not, and the industry pages linked to no service at all.

Each industry page's own description already tells the reader Queue North
"delivers phone, contact center, network, and IT support" for that industry, so
all four qualify. The anchors are descriptive rather than the page names:
"Business phone and UCaaS solutions" and "Contact center and CCaaS solutions".

Rendered through the shared RelatedLinks component, in the industry sidebar card
and after "Ideal For" on a service page. It renders nothing without links, so
the five service pages that have none are untouched: only 5 of the 18 pages gain
a Related services block, and they are exactly the five the sheets name.

Verified against the build: both hrefs are present on managed-support and on all
four industry pages, the built-HTML audit passes with every link and fragment
resolving, and no other page gained the block.

Closes #221.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:59:49 -05:00
null 2f1e24892a fix(seo): the production sitemap had no dates, and the build context had secrets
Three things, all in the path between this repository and the running image.
Closes #225, #224 and #223.

1. THE PRODUCTION SITEMAP CARRIED NO LASTMOD AT ALL. Dates come from git
   history, and the image build cannot see git: .dockerignore excludes .git and
   node:alpine has no git binary. prerender.js read the failure into an empty
   catch commented "git unavailable or file untracked", so all 18 URLs came out
   undated while the build printed a success line. Local builds looked perfect,
   which is why nobody caught it.

   release.sh now computes the map where git exists, passes it as the
   SITEMAP_LASTMOD build arg, and then asks the built image whether its sitemap
   has dates, refusing to publish one that does not. prerender prints the count
   on every run, so "18 URLs, 0 dated" can never again read as success. The
   route-to-source map moved into scripts/lib/routes.js, where a service page
   now also counts its own content file, so editing one page's copy moves that
   page's date and no other.

   Proven: an image built with the arg carries 18 lastmod entries; a build with
   git deliberately unreadable and no arg reports "18 URLs, 0 carrying a
   lastmod" and warns.

2. THE DOCKER BUILD CONTEXT CARRIED CLIENT MATERIAL AND LIVE SECRETS. .drop/,
   zoho.md (the reCAPTCHA secret and the Zoho tokens), Levi.md and two 30 MB
   zips were all sent to the daemon on every build, along with four agent
   workspaces. The final image copies only built output, so none of it ever
   shipped, but one careless COPY would have changed that. Proven by listing the
   context from inside a throwaway image: before, all of it; after, none of it.

3. UNTRACKED FILES PASSED SILENTLY. docker build packs the working tree, so an
   untracked module the code imports produces an image that works and a tag that
   cannot rebuild it. release.sh now refuses while untracked files are present,
   and pre-commit's note counts them too.

#223 also claimed post-commit hides a refused push. It does not: it printed
"push was refused. The commit is safe locally and the branch is now ahead."
during this batch. The issue was corrected on the tracker rather than acted on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:58:37 -05:00
null 7cd556c4b5 fix(qa): the browser check measured 5 of 18 pages and heard no errors
Two gaps in the tool that exists to find defects.

Its default path list was five pages typed by hand: home, about, services,
contact and support. That is 5 of the 18 this site serves, and it included no
service and no industry page, so a defect on any of them was invisible to it.
The default is now whatever the target's own sitemap says it serves, and a
sitemap it cannot read exits 2 rather than quietly checking nothing.

It also listened for nothing the page said. A page that throws still paints, so
every measurement it takes can look healthy while the page is broken: React
reported a hydration mismatch on every page of this site for months and this
tool never noticed, because nothing here was listening. It now captures page
errors and console errors and reports them as findings.

Proven: a console.error added to the About page produced
"/about @390: the page logged an error", and a clean build reports nothing
across all 18 sitemap pages.

docs/qa/ClaudeQAPlan.md said "five pages, five widths" and it is now every page
in the sitemap, at four widths.

Closes #227.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:53:51 -05:00
null ee5186c1ee feat(build): audit what the site actually serves, in the build and on the wire
Everything here checked an input: the content check reads the data, the secret
scan reads the diff, the build reads the source. Nothing read the OUTPUT, which
is the only thing a visitor or a crawler ever sees. Two live defects made the
case: every page preloaded the wrong image for months, and eleven pages shipped
a run-on description. Both are plain in the built HTML and invisible in the
source.

Build mode is guard 15-built-html, after 10-build. Per page it requires exactly
one title, one non-empty description, one canonical equal to the site origin plus
the route, and one h1; JSON-LD that parses, with no FAQPage, which the owner
ruled out; no em dash and no U+FFFD; a preload naming the image the page actually
paints first; and no description that runs its short description into the next
sentence. Across pages it requires every internal link and every fragment to
resolve, the sitemap to list exactly the routes the site serves, and 404.html to
carry noindex and no canonical. It exits 2, not 0, when dist/ is missing or older
than the sources: auditing stale output is auditing nothing.

That also guards a specific hazard. react-helmet-async on React 19 does not
merge, so a second <SEO> anywhere on a page silently emits a second title and a
second canonical, and a search engine picks whichever it likes.

URL mode fetches every page in a live sitemap once per crawler user agent
(OAI-SearchBot, PerplexityBot, ClaudeBot, Googlebot, bingbot), requires HTTP 200
and identical bytes across agents, runs the same page rules, and reports any URL
without a lastmod. It is deliberately NOT wired into deploy.sh: a check that runs
after publication cannot stop it, and pretending otherwise is worse than not
having it. Run it after a deploy.

Proven by mutation, nine of them, each restored afterwards: a wrong canonical
(7 pages), a second h1 (4), FAQPage markup, an em dash in copy, a link to a route
that does not exist (18), a fragment that is not on its target page (7), the
preload keyed on the old attribute (19), the template title left in place giving
two titles (19), and the industry routes dropped from the route list (56). A
clean build audits clean, and URL mode passes against the local server as all
five crawlers.

Closes #228.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:51:08 -05:00
null 7ec4241f7d refactor(ui): one content renderer, and it refuses a block it does not know
The privacy policy held the site's only block renderer, and the two approved
service pages need the same vocabulary plus ordered steps, a figure and a link
list. Two renderers would have drifted, so there is one:
src/components/content/ContentBlocks.jsx, with p (including inline links), h3,
ul, ol (a step, optionally with its explanation), callout, image and links.

The old renderer sent an unknown block type to its paragraph case, which
rendered an empty <p>. A misspelt type silently dropped a paragraph of copy and
nothing said a word. The shared one throws, and the prerender turns that into a
build failure naming the route, the section and the block index.

PrivacyPolicy passes in the two block types only a policy has, email and
contactBlock, rather than forking the renderer. That page is the one an ad
platform must be able to read, so its markup had to be untouched: the renderer
was written to emit exactly what the page emitted before, prop order included.

Proven: dist/privacy-policy/index.html is byte for byte identical before and
after, 82,392 bytes either way, with asset hashes normalised. And a block type
misspelt as 'emial' fails the build with "prerender: /privacy-policy could not
be rendered: content: section "marketing", block 3: unknown block type".

RelatedLinks is the second shared piece, for the links the sheets ask each page
to carry. It renders nothing when a page has no links, so the pages that have
none are unchanged.

Closes #229.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:45:48 -05:00
null 0d575f2977 feat(build): the copy is checked before a single page is built from it
src/data is prose in a data structure, and nothing checked it. The long-form
service pages make that dangerous in a specific way: their copy arrives as an
owner-approved markdown sheet that MIXES DIRECTIONS TO THE WEBSITE MANAGER INTO
THE COPY. "Do not promise that every number is always portable." "Keep this
factual:" "Place an official 8x8 Work screenshot beside this section." Those
lines look exactly like copy, and publishing one puts an internal instruction on
a customer-facing page.

scripts/lib/content.js decides whether the content layer is publishable, and
prerender.js runs it before rendering anything, so every build enforces it: the
pre-commit hook, npm run verify, and the Docker image build. It refuses a
website-manager direction, an em dash, a U+FFFD, markdown or an HTML tag left in
a string, an unknown block type, a section id that is not letter-first, unique
and free of the layout's own ids, a section that does not open with its direct
answer (unless it declares kind list or faq), a FAQ question with no answer, a
link to a route or fragment that does not exist, an image whose src is missing
from public/ or has no alt or no dimensions, and the missing benefits or
idealFor list that the short layout maps without checking. A description over
160 characters is a note, not a failure: owner-approved copy is published as
written.

scripts/lib/routes.js is now the one route list. prerender.js built its own
while src/routes.jsx built the router's, and nothing compared them: a route in
one and not the other is never prerendered, so the server answers it with
404.html while the site's own navigation links to it. entry-server.jsx exports
the router table so the build can compare the two.

Proven by mutation, seventeen of them, each expecting exactly one finding and
getting it: unknown block type, FAQ answer removed, answer moved below its list,
duplicate id, digit-leading id, link to /services/contact-centre,
#no-such-section, missing image file, image without dimensions, image without
alt, em dash, a manager direction, markdown bold, U+FFFD, an HTML tag, missing
h1, empty section. Both generated content modules pass unmutated. Against a real
build: an em dash added to industries.js failed npm run build naming the field,
and a /pricing route added to src/routes.jsx failed it naming the route.

Closes #230.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:43:01 -05:00
null a6b87c7123 fix(ui): React was throwing away the prerendered page on every route
Suspected from the code while planning Batch 17, then confirmed in Chromium:
every page logged React error #418, a hydration mismatch. React answers a
mismatch by discarding the server DOM and re-rendering the page on the client.
So the prerender ran, crawlers received it, and every visitor's browser threw it
away and did the work again.

Two causes, both ours:

1. prerender hoisted the JSON-LD scripts out of the body into <head>. React
   hoists only async scripts with a src, so on the client that script stays
   where its component renders it. The DOM and the client's first render
   therefore disagreed on every page that emits structured data. JSON-LD is
   valid anywhere in the document, so it now stays where React puts it. Title,
   meta and link tags are still hoisted, because React hoists those itself.

2. main.jsx rendered sonner's <Toaster> in the first client pass, and the server
   entry never rendered one, so the client expected a <section> the prerendered
   HTML did not have. It mounts after hydration instead, which costs nothing: a
   toast can only follow an interaction.

Measured in a real browser, all 18 sitemap pages, before and after: hydration
errors 18 to 0, other console and page errors 0. Each page keeps its
server-rendered DOM (an h1 stamped before hydration survives), and still has
exactly one head title and one canonical. Structured data is unchanged in
substance: 27 JSON-LD blocks across 19 pages, and OAI-SearchBot still receives
Service, BreadcrumbList and Organization on the contact-center page.

Closes #226.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:37:33 -05:00
null b260bca24f fix(ui): a link with an odd fragment took the whole page down
document.querySelector THROWS on a fragment that is not a valid CSS selector,
and an id starting with a digit is not one. The call sat in an effect inside the
root route, so the error boundary replaced the header, the page and the footer
with the error screen. Live today: /services#1 blanks the page. Confirmed in
Chromium, where querySelector('#1') raises a DOMException.

That matters now because the long-form service pages exist to be deep-linked
from search results and AI answers, and two of their sections are about 8x8. A
link nobody here wrote must not be able to blank a page.

getElementById takes an id rather than a selector, so it cannot throw on one,
and the decode is wrapped for a fragment that is not valid percent-encoding.
An unknown id scrolls to the top, as before.

Proven in a real browser against the built site: /services#1, /#%E0 and
/services#8x8-implementation all render with header, footer and h1 intact and no
page error, and /contact#contact-form still scrolls to the form.

That run also confirmed at runtime what #226 suspected from reading the code:
every page logs React error #418, a hydration mismatch. It is pre-existing and
unrelated to this change, and it is fixed next.

Closes #231.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:34:41 -05:00
null 28b07abc28 fix(build): the prerender preloaded the logo, hoisted any tag, and hid its errors
Four faults in the one build step that decides what a crawler receives.
Closes #233 and #232.

1. Every page preloaded /logo.png. The hero hint was keyed on the first
   `loading="eager"` image, and that is the header logo, on every page. So the
   image each page actually paints first was never preloaded, and React's own
   preload for it was being discarded as a duplicate. It now keys on the image
   React marked with a high fetch priority, matched case-insensitively, because
   React writes the attribute camelCase in HTML and a case-sensitive match would
   have quietly removed every preload instead.

2. The hoist moved ANY title, meta or link out of the body into <head>. An
   inline <svg><title> is a picture's label, and microdata rides in
   <meta itemprop>: both would have become page-level head tags the moment the
   long-form copy carried an icon with a title. SVG blocks are now parked before
   the hoist, and itemprop tags stay where they are.

3. `page.replace('</head>', body)` interprets `$&`, `$'` and `$$` INSIDE the
   replacement, and the replacement is page copy. React escapes & into &amp;, so
   any `$` immediately before an escaped character injected markup. Copy carries
   no `$` today; the next page with a price would have. Replacement is now a
   split and join, and each marker must appear exactly once.

4. A render error named no route, and a Suspense fallback shipped silently as
   an empty page. Both now fail the build and say which route.

Also refuses to run over its own output: dist/index.html is both the template
and the home page, so a second run without a rebuild gave every page two
canonicals.

Proven by mutation, each restored afterwards: a probe <svg><title> in the footer
stays in the body on all 19 pages and no page gains a second title; a Suspense
boundary fails the build naming the route; a second prerender run refuses; copy
reading "Save $10 & more" reaches the page literally with one root div; and
every page now preloads its own hero, with none preloading the logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:32:33 -05:00
null fef8b0718c fix(seo): descriptions were two sentences run together on all eleven detail pages
Live until now, on every service and industry page:

  "...seamless integration Delivered by Queue North, a veteran-owned..."
  "...for medical providers Queue North delivers phone, contact center..."

clampDescription joined its fragments with a space, and no shortDesc in
services.js or industries.js ends in a full stop. Nothing added one. The
description is the first thing a searcher reads, and it read as a run-on on
eleven of the eighteen pages.

buildDescription replaces it and joins fragments AS SENTENCES. It also takes an
`approved` description, which it emits verbatim: never joined, never clamped.
Levi's approved copy for the two long-form pages is 164 and 191 characters, and
the old 158-character clamp would have cut his sentences off with an ellipsis.
ServiceDetail reads it from `page.seo`, which the content commit fills in.

ServiceDetail and IndustryDetail now build their canonical from SITE_URL rather
than repeating the origin. The three files release.sh greps for that literal are
untouched, so the origin guard still has three copies to compare.

Verified against a build, page by page: all 11 detail descriptions gained
exactly a full stop and changed in no other way, and no other page's description
moved.

Closes #234.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:28:25 -05:00
null aab0104b01 ui: no em dashes in anything a visitor or a crawler reads
Null's direction of 2026-09-10. Punctuation only: not one word changed.

Nine of them, and the share-image alt text was on every page twice:

  src/components/SEO.jsx:4        the default og:image and twitter:image alt
  index.html:25,:31               the same string, as template fallbacks
  src/pages/Home.jsx:257,:290
  src/pages/Services.jsx:99
  src/pages/Contact.jsx:183
  src/data/services.js:120        the wireless card on Home and the index
  public/site.webmanifest:4       linked from every page

Thirteen more sit in code comments and reach no page, so they stay. The hours
range on /contact uses en dashes, which are a different character and are not
what was asked about.

This lands before the guards that reject an em dash in content or in built
HTML, because until now every built page carried one and those guards would
have gone red on arrival.

Verified against the build, not the source: no U+2014 in any file under dist/,
including the HTML, the webmanifest, the sitemap and the JS bundle.

Closes #222.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:26:21 -05:00
null 26136f4e5c security: the scanner never ran its private-key pattern, and verify never saw dist
Three faults in one guard, the only thing standing between a credential and a
pushed commit. Found while planning Batch 17; closes #235.

1. grep was called without -e, at both call sites. The private-key pattern
   starts with dashes, so grep read it as an option, exited 2, and the beside-it
   2>/dev/null threw the complaint away. A staged private key passed the
   pre-commit hook, and had since the rule was written. Proven in a scratch
   repository before and after.

2. The 2026-08-29 pattern, committed as-is in 7415e19, flagged two comment
   lines that exist to show the shape of a credential. verify was therefore red,
   and release.sh would have died after bumping package.json, package-lock.json
   and the Dockerfile, leaving a tree that pre-push then refuses.

3. verify never scanned dist/, though SECURITY_CHECKLIST.md has listed the
   bundle scan as a release check for months. A key can reach the bundle from an
   environment variable inlined at build time without ever being committed.

What changed, beyond -e: every pattern is compiled against empty input before
the scan and an unreadable one exits 2, because silence from a broken matcher
looks exactly like a clean tree. The NAME=value pattern now also catches quoted
values, which is how a real secret is usually written down and which it has
always missed. A line that must show a credential shape carries `secrets-ok:`
and a reason, which excuses that line alone and stays visible to review and to
grep. Guard 20-secrets runs --tracked and --built dist/.

Proven by mutation, per GUARDS.md: a staged PEM header, a bare API_KEY=, a
quoted API_KEY=, an exported secret and an AWS key id each exit 1; a ${VAR}
value, a <placeholder>, an excused line and a delete-only commit each exit 0; an
unreadable pattern exits 2 with content staged and with none; a key planted in
dist/ fails guard 20-secrets alone, masked in the report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:23:39 -05:00
null 7415e190e3 chore(security): commit the Aug 29 scanner update left uncommitted here
The 2026-08-29 cross-project update of scripts/secrets.sh reached this
checkout as a working-tree edit and was never committed. For the twelve days
since, .githooks/pre-push refused every push, because a tracked file was
modified, so nothing else could land.

Committed as-is, by Null's decision, so the work that follows can push. What
the change does:

- the bare NAME=value pattern can now match. It was anchored to the start of
  a line, and neither scan mode ever presents one: a staged diff starts every
  line with "+", and --tracked prefixes each line with its file name.
- a commit that only deletes lines is no longer refused as a possible
  credential. Staged mode reads added lines only, so "nothing to scan" there
  is a correct measurement, not a failure to measure.

Known, and fixed in the next security commit (Batch 18): the new pattern
flags two comment lines, so `npm run verify` fails until then, and the
private-key pattern still never runs because grep is called without -e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:08:06 -05:00
Ripley a25077dea7 docs: log Batch 11 and Batch 16 — four false, two real, found by rendering
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:09:31 -05:00
null b35f171b8c chore(release): v0.9.5 2026-08-18 04:06:22 -05:00
Scarlett 231f07c62d fix(ui): header CTA clipped at iPad portrait, and reCAPTCHA cut off at 320px
Two real defects, found by rendering the site rather than reading it. Neither
was among the twenty issues filed for Batches 10 and 11.

#214 (P1) — at exactly 768px, iPad portrait, the header's "Request
Consultation" CTA measured x 676-778 against a 768px viewport: 10px sliced off,
with no scrollbar to reveal it because html/body carry overflow-x:hidden. At
that width the burger menu is already hidden, so the primary conversion action
was simply unreachable. The nav's five gap-6 gaps were the slack; gap-4 at md
(gap-6 from lg) frees 40px, keeping the CTA on screen at md rather than
deferring it to lg and leaving 768-1023px with no CTA at all.

#215 (P2) — Google's reCAPTCHA checkbox iframe is a fixed 304px that cannot be
resized. At 320px it measured x 41-345, so 25px of branding and the privacy and
terms links were clipped. Scaled to 0.85 below 360px with the wrapper height
reduced to match, since transform does not affect layout and the form would
otherwise gain dead space.

Also adds scripts/qa-browser.mjs, which found them. It measures horizontal
scroll, broken images, elements past the right edge, CLS and LCP across pages
and viewports. Written because seven of the ten issues in Batches 10 and 11
misstated their own evidence, and two of those would have made the site worse
if actioned.

Two false positives in the tool itself, both fixed before trusting it:
- Lazy images below the fold read as broken. It now scrolls the page AND chases
  each un-loaded image individually, and reports observed HTTP failures apart
  from never-rendered images.
- `--paths / --viewports 320` swallowed "320" as a path. Argument collection now
  stops at the next flag.

Verified against a local production build: 5 paths x 5 viewports, zero findings.
Before the fix the same run reported the CTA overflow at 768 on four pages and
the reCAPTCHA overflow at 320 on /contact.

Not yet live — this needs a release and a deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 04:05:37 -05:00
Ripley cf92354d96 docs: v0.9.4 is live, production is on a numbered version, and Watchtower is out
Corrects the runbook to what is now true rather than what was true this morning:
stack 58 pins :v0.9.4, not :dev; the CORS trailing slash is fixed and #212 is
closed; and status.sh --deployed-version answers for the first time.

Adds the Watchtower section. Stack 39 runs nightly at 02:00 watching every
container, and qn-website-dev had no opt-out while following a moving tag — so
the site was updating itself overnight. That was the undocumented deploy path.
It now pins a version and carries the opt-out label, and the consequence is
stated plainly: nothing updates this site automatically any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:38:02 -05:00
null 0decc03642 chore(release): v0.9.4 2026-08-18 03:34:19 -05:00
Neo b24cf3589f fix(deploy): stop two ways this tooling would have broken production
Both found by checking before running, not by running.

1. release.sh would have shipped a site whose contact form cannot be submitted.
   .dockerignore excludes .env from the build context, and the script passed
   --build-arg VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-} without ever
   loading .env — and the variable is unset in every shell. The `:-` made empty
   silently acceptable. An empty key makes RecaptchaPlaceholder render "Security
   verification is not configured." and produce no token, and the server has
   RECAPTCHA_ENABLED=true, so every submission is rejected. Lead capture stops.
   Now: loads VITE_* from .env, refuses an empty key outright, and greps the
   BUILT image's bundle for it before pushing — ask the artifact, do not trust
   the wiring, the same move already used for the version label.

2. deploy.sh's default would have rolled production back two months. The newest
   published NUMBERED tag is v0.8.3, built 2026-05-28; the running :dev image
   was built 2026-08-01. v0.8.3 has no privacy policy and no prerendered routes
   at all. Now: the target's build date is read from the registry without
   pulling it, compared against what is running, and refused if older unless
   --rollback is passed.

Also in deploy.sh:
- --fix-cors removes the trailing slash from CORS_ORIGIN (#212), and
  --watchtower-off adds com.centurylinklabs.watchtower.enable=false. Both ride
  in the same PUT so production restarts once, not three times.
- The stack file is now read to a temp file instead of $( ), which was stripping
  its trailing newline — a change beyond the lines the script claims to touch.
- The env-line count is asserted before sending. Verified with `docker compose
  config` why that matters: without the Env array the ${VAR:-false} defaults
  resolve reCAPTCHA and Zoho WebToLead to false, so the container would come
  back healthy and quietly stop capturing leads.
- Post-deploy it checks health, both origins, AND that the live bundle carries
  the site key the stack declares — the failure no health check can see.
- Any of those failing triggers an automatic rollback to the original stack
  bytes, once. If the rollback also fails it stops and says so rather than
  retrying, because a script retrying an outage is how a short one becomes long.

The resulting stack file was validated with `docker compose config`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:33:20 -05:00
Ripley e0f62d7a1f docs: log the numbered-version policy and what it changed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:55:12 -05:00
Neo 1a3ba305a7 feat(deploy): production always runs a numbered version
Policy set by _null. The stack pins :vX.Y.Z — never :dev, never :latest.

The reason is what a pointer cannot do. A stack file naming :dev says "dev"
today and will still say "dev" after the image underneath it changes, so the
file records a preference rather than a fact and "what is running?" has no
answer that survives the next push. Rollback is worse: there is nothing to roll
back TO, because a pointer only ever names now.

deploy.sh: with no --tag it now resolves the newest published NUMBERED version
from the registry and says which it picked, instead of defaulting to :dev. A
floating tag is refused outright, with --allow-floating as the loud escape hatch
for the one case that is not a mistake — reproducing a fault on whatever a
pointer currently is.

release.sh: no longer moves :dev. It published both tags until now because the
stack followed the pointer; under this policy nothing deploys :dev, so moving it
would only publish something that misrepresents what is running.

scripts/docker-push.sh deleted, with its npm script. Its entire job was building
and pushing an unnumbered :dev with no version bump and no guards, which is now
the exact thing the policy exists to prevent. Keeping a command that quietly
violates a policy is how the policy stops being true.

:dev and :latest stay in the registry, frozen. Not deleted: the running
container was created from :dev, and removing the tag an existing deployment
names is how a recreate fails to pull.

Stack 58 still pins :dev as this lands. Correcting that is the first deploy made
under the policy, and it is why the default resolves a version rather than
reusing whatever the stack already names.

Guards proven: default resolves v0.8.3; --tag dev refused; --tag latest refused;
--allow-floating warns and proceeds; unpublished tag still refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:54:42 -05:00
Ripley 6a890ad8bc docs: log the release and deploy scripts, and what did not transfer from PrivacyLLC's
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:42 -05:00
Neo 2ae7352799 feat(release): adapt PrivacyLLC-Web's release.sh, and add the deploy half it deliberately omits
scripts/release.sh — publish. Bump, guards, build, verify the image's own
version label, push :vX.Y.Z and move :dev, commit last, tag.

Adapted, not copied. The arguments are PrivacyLLC's and were paid for there; the
mechanism is this project's, because almost none of it transferred. Three
differences, each a fact about this repository rather than a preference:

- It gates on verify.sh, not a test suite, because there is not one. The
  original refuses to release on a half-run 1,600-test run. This one says out
  loud that a build, a secret scan and a doc-header check are not tests and that
  nothing in the gate touched a route, a form or an API response.
- It moves :dev as well as publishing :vX.Y.Z, because Portainer stack 58
  follows :dev. That makes :dev a pointer and never evidence.
- It checks the public origin in three files, not one. The original passes its
  origin in as a build arg; here https://queuenorth.com is written out in
  src/lib/seo.js, src/components/SEO.jsx and scripts/prerender.js and is baked
  into every canonical URL, og:url, sitemap.xml and robots.txt. The guard asks
  whether the three still agree.

Why this was needed: publishing was `npm run docker:push` with the bump as a
separate thing to remember, and it was not remembered. package.json said 0.8.3
while four commits announced "batch 0.9.0" through "0.9.3", and NO image was
ever published for any of them — the registry's newest tag is v0.8.3.

No prune. The original has one; this project has published thirteen tags in its
life, deleting a published image is irreversible, and the one that matters is
whichever the container was created from — exactly what a newest-N rule gets
wrong.

scripts/deploy.sh — deploy, and do not build. The template's deploy.py builds,
pushes AND deploys; adopting it beside release.sh would mean two commands that
both build, a second image for the same code, and two answers to "what is
running". This does only the missing half: point stack 58 at an already
published tag, having taken a verified backup first.

Its most important guard is not the obvious one. Portainer treats a stack PUT as
the whole desired state, so sending it without the stack's Env array would strip
twelve variables — the reCAPTCHA secret and the Zoho form tokens among them —
and the container would come back HEALTHY while quietly capturing no leads. It
reads them, counts them, sends them back, and refuses outright if none.

Guards proven to refuse, not assumed: already-published tag, http origin,
trailing-slash origin, the three origin constants drifted, dirty tree, bad flag,
unpublished deploy tag, missing Portainer key, wrong stack id.

One real bug found and fixed while testing: the image-line rewrite used
`python3 -` with a heredoc while also piping the stack file to stdin, so python
tried to execute the YAML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:49:03 -05:00
Ripley e3c360008e docs: log Batch 10, and record that four of its six issues misstated their evidence
Pass F moves from "not run" to "partial": a static audit did happen — contrast
computed, accessible names checked against visible text — but nothing has been
walked in a browser and no screen reader has been used. A focus ring proven at
13.62:1 in arithmetic has still never been seen.

Adds the standing gap that matters more than the individual fixes: four of six
filed accessibility defects had wrong numbers or an inverted fix direction, all
six having been written from reading markup. A filed defect's figures are a
claim to check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:35:45 -05:00
Neo 77d5131473 chore(docker): label the image with its version so status.sh can report it
scripts/status.sh --deployed-version reads org.opencontainers.image.version and
the image carried no such label, so on 2026-08-18 it correctly answered
"unknown" and the digest was the only way to tell one deploy from another. Step
3 of the incident runbook depends on that command.

ARG APP_VERSION defaults to 0.9.3 and can be overridden at build time.

Verified by building: docker build succeeds and
`docker image inspect --format '{{json .Config.Labels}}'` reports
org.opencontainers.image.version = 0.9.3, plus title and source.

Takes effect on the next image build. OPERATIONS.md says so rather than
implying the running container has it — it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:34:27 -05:00
Scarlett 9fdc6b6032 fix(a11y): visible focus indicator, aria-expanded, and six Label-in-Name failures
Batch 10, and only three of its six issues were real. The other three are
answered on the tracker with the arithmetic.

#189 — focus indicator. Footer and Home carried zero focus styling across 17
links. Added one global :focus-visible rule rather than sprinkling classes,
because the issue's own scope was "all interactive <a> and <Link> elements".

Not the ring the issue asked for. It suggested ring-primary-cyan; #22D3EE is
6.26:1 on navy and 2.38:1 on white, so on this light-first design that ring
would have failed WCAG 1.4.11 across most of the site. Two rings instead — white
inside, navy outside — so the white carries the dark bands and the navy carries
the light sections. Worst case across every background in the palette is
13.62:1. box-shadow so both rings follow each element's own border-radius, plus
a transparent outline for forced-colors mode.

#192 — aria-expanded={isOpen} and aria-controls on the mobile nav trigger, and
the id on SheetContent it now points at. Confirmed as "false" in the
prerendered HTML.

#193 — INVERTED. The issue asked for aria-labels to be added consistently to
hero CTAs. The aria-labels already there were WCAG 2.5.3 Label in Name failures,
Level A: "Schedule a consultation" is not a superset of the visible "Schedule
Consultation", so a voice-control user saying what they see cannot activate the
link. Doing what the issue asked would have spread a Level A failure. Removed
the six that broke it; kept the eight that genuinely add context and do contain
their visible text, including the icon-only header logo link that needs one.

Audit now reports 0 failures across Home, Services, Footer and Header.

Verified: npm run build; the ring is in the built CSS, aria-expanded="false" and
id="mobile-nav-content" are in the prerendered HTML, all six removed labels are
absent from it, and the visible CTA text still renders.

NOT verified: nothing has been walked keyboard-only in a browser, and no screen
reader has been used. That gap is recorded in docs/qa/ClaudeQACoverage.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:31:13 -05:00
Ripley 615043db44 fix(infra): queuenorth.com is the production origin, and it is this deployment
_null confirmed queuenorth.com as the permanent public origin, which settles
#212 and exposed a wrong claim written earlier the same day.

docs/OPERATIONS.md said queuenorth.com "is not this deployment ... do not
diagnose against it". That came from a DNS lookup and an assumption. It is this
deployment: both hostnames serve the identical bundle and this server's own
/api/health shape, 24.41.108.95 is this network's own public IP, and both reach
qn-website-dev on nebula — queuenorth.com through nginx-proxy-manager on
thor/exodus, qn.isnull.dev through Cloudflare. Two front doors, one container,
no non-production environment.

That is the worst direction for a runbook to be wrong in, so the correction
quotes the wrong sentence rather than replacing it silently. The QA Round 0
table likewise gained the production observations as extra rows instead of
having its originals rewritten.

Dockerfile: the CORS_ORIGIN fallback was '*'. The server sets credentials:true
and browsers reject '*' with credentials outright, so that fallback would have
broken every form rather than over-permitting. Now the real origin.

healthcheck.sh and preflight.sh now watch production by default, with the
second front door reachable through their env overrides — the two ingresses
terminate TLS in different places and can rot independently.

Also fills a gap adoption left explicitly undone: the deploy path is a Portainer
stack, id 58 on nebula, found from the container's own compose labels.
OPERATIONS.md documents it, including that the stack file is a separate copy
from this repository's docker-compose.yml and the two have already drifted.

That drift is all that remains of #212 — one trailing slash on line 21 of the
stack file. Left in place: nothing is broken today, and fixing it recreates the
container and takes both front doors down together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:08:12 -05:00
Ripley 2732d1fc4f fix(security): stop secrets.sh flagging every prerendered page, and clear the dangling doc claims
secrets.sh --built reported ten credentials in dist/ and all ten were the same
false positive: the template's user:pass@host pattern reads the schema.org
JSON-LD on every prerendered page — //queuenorth.com"},"areaServed":{"@ — as a
host, a password and an @. One more finding for every page added, which is the
noise that turns a scanner into something people mute.

Quotes, braces, commas and angle brackets cannot occur in a real userinfo
component. Checked against a database URL with an inline password, one
percent-encoded, and a git remote carrying a token — all three still caught, all
ten false positives gone, and the historical Zoho leak from 033bdf6 still caught
when replayed.

The first version of that fix wrote its three test cases out literally in the
header, and --tracked then reported two credentials in the scanner itself. The
placeholders now use angle brackets, which are in the exclusion class the
comment is describing — so the examples cannot match the pattern they
illustrate. Same shape as the trap DOC_TRUST_MAP.md records about Exempt: lines.

doc-claims: 240 claimed paths, all present, up from 5 dangling. DOC_TRUST_MAP
was claiming banner.webp exists while saying it does not; GUARDS.md pointed at
prove-guard.sh, which this project declined. docs/history/ is excluded rather
than corrected — its entries name files that existed when they were written, and
editing an append-only log to satisfy a present-tense check is a category error.
TOOLS.md records the exclusion and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:40:46 -05:00
Ripley 493b97e4a4 docs: record two Command Center traps that each cost time today
Both are silent in the expensive direction, which is why they are written down
rather than remembered.

A webhook registered without the shared signing secret looks entirely healthy
in Forgejo — active:true, test deliveries accepted with 204 — and has every
delivery rejected by privacyllc.dev with 401 bad_signature. Forgejo exposes no
delivery history through its API, so nothing says so. This repository ran that
way for ten minutes today. Includes how to prove a delivery lands.

A check-in's projectId is the UUID, not the slug, unlike every other route in
that API. A slug returns a generic {"code":"unknown"} 500 that names no field
and is indistinguishable from a genuinely malformed request.

Also records that neither reconcile_all nor sync_projects forces a re-read of a
project inside its freshness window, and that the agent API has no per-project
force — so refreshed=0 means "not due", not "nothing to do".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:36:46 -05:00
Ripley 1d952a0170 docs: log the template adoption, with next action and blockers
Step 6 of docs/WORK_CYCLE.md. Records what the adoption proved rather than
what it did — the two facts that were not known this morning are that the lead
database is restorable, and that three defects were sitting in the tree that
the adopted tooling found on the way in.

Next action: start Batch 10 (accessibility), #189 first, and walk the pages
keyboard-only before fixing anything — all six were found by reading markup.

Blockers: none on this work. Six issues are blocked on the site owner, two of
which are unsubstantiated claims live on the site since May.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:31:17 -05:00
Ripley 8e186fdf3f chore: port the existing pre-push hook into .githooks before activating hooksPath
This checkout already had a pre-push in .git/hooks. Setting core.hooksPath
redirects every hook to .githooks/, so activating the template's hooks would
have silently stopped it running — which is the exact failure the versioned
hooks directory exists to prevent, applied to a hook.

Kept its two working-tree checks: a push that leaves edits behind is how docs
end up one commit adrift of the code they describe.

Dropped its third. It refused whenever the branch was ahead of its remote —
the precondition for pushing at all — so it fired on every real push and its
only advice was to re-run with --no-verify. A guard that can never pass teaches
people to bypass the two beside it that can. It did exactly that on the push of
the adoption commits.

Both remaining checks proven to refuse a dirty tree and pass a clean one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:22:33 -05:00
Ripley aa1d805e04 docs: adopt project template, retire the markdown backlog
This repository is the one the template's README describes: 205 issues, zero
milestones, and severity labels spelled "P0 Critical" / "P1 High" / "P2 Medium",
which privacyllc.dev reports as NOT ADOPTED rather than as 87% complete.

Six markdown records described the same work and none pointed at the tracker.
Two of them said the project was in "Phase 5" while the code was at 0.9.3.

Migrated, then deleted in this commit:
  FUTURE.md           -> docs/history/BATCH_LEDGER.md (Archived). Its open
                         items were all already filed as issues, so nothing
                         needed migrating into the tracker
  HISTORY.md          -> docs/history/DEVELOPMENT_LOG.md, verbatim, 0 lines lost
  DEVELOPMENT_LOG.md  -> the same file, as a second labelled block. Not
                         interleaved: the changelog has three duplicated version
                         headings, so one date order would have implied more
                         than the record supports
  PROJECT.md          -> docs/planning/PROJECT_PLAN.md
  STRUCTURE.md        -> the agent pipeline into README.md; its versioning rules
                         retired
  BUILD_SUMMARY.md    -> BATCH_LEDGER.md. Its embedded SQL schema deliberately
                         NOT carried: it predated the UNIQUE constraint on
                         leads.email, and server/index.js owns the schema
  SCRIPTS.md          -> docs/TOOLS.md, corrected for the SSR + prerender build

Moved with history (git detects all four as renames):
  OVERHAUL_PLAN.md, review.md, project-requirements.md, docs/zoho-setup.md

Kept because this project earned them: the five-agent pipeline, the design
system in OVERHAUL_PLAN.md (Status: Current, with a front-note saying which half
is history), the positioning argument in REDESIGN_REVIEW.md, and REQUIREMENTS.md
whole, including its change policy.

Deleted from the template because they do not apply, each said out loud in
DOC_TRUST_MAP.md: QA pass I (no money moves), the authorisation checklist group
and the session-token row (no accounts, no sessions), and one PRECAUTIONARY
paragraph in SECURITY.md about holding credentials on behalf of users — there
are none, and PROJECT_PLAN.md records accounts as out of scope. Pass H was kept
and rewritten: its authorisation half does not apply, its what-a-stranger-can-
reach half is the most exposed surface here.

Also removed: main.js, the old static site's hash router, referenced by nothing
and preserved in .drop/; and test-results/.last-run.json, a May Playwright
artifact reading {"status":"failed"} for a suite that does not exist.

The repository was made private on Forgejo before this commit. That is what let
the internal history be committed rather than exempted — null/fruit-fall is
already private and reports normally.

Two defects found on the way in and fixed here: zoho-setup.md told admins to
edit `server/zoho/`, a directory that has never existed in any commit (the
mapping is in server/index.js), and README.md's route list still advertised
/8x8, removed at 0.6.6, while omitting /privacy-policy.

Branding: icon.webp and logo.webp converted from this project's own marks in
assets/. banner.webp is absent and is filed as an issue rather than faked.

Verified: verify.sh 3/3, doc-claims 71 claimed paths all present, backup and a
first-ever restore of the live leads database (2 tables, 3 rows, under 1s).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:21:13 -05:00
Ripley 5c04195cb1 chore: adopt template scripts and git hooks, retire phase-versioning
Ten scripts from ~/.openclaw/Projects/Template, taken one at a time and
configured against this deployment rather than copied wholesale.

Configured, not just copied:
- check-env.sh   SPEC written from what server/index.js actually reads — 24
                 variables, each with the consequence of getting it wrong
- secrets.sh     plus this project's own shapes: a bare 60+ hex run, which is
                 how the Zoho WebToLead tokens leaked into four commits, and a
                 reCAPTCHA key shape as NOTED rather than a failure, because the
                 site key and the secret key are indistinguishable by shape
- status.sh      nebula / qn-website-dev
- healthcheck.sh /api/health, asserting 200 AND "status":"ok" AND "db":"ok".
                 The template probed /healthz, which does not exist here
- preflight.sh   https://qn.isnull.dev, no --auth — there are no accounts
- verify.sh      GUARD_DIR=scripts/verify.d, since this project has no test
                 runner and no typecheck for it to detect
- backup.sh      ENGINE block replaced for SQLite: better-sqlite3's online
                 .backup() inside the container, verified with PRAGMA
                 integrity_check before anything is renamed into place
- restore-check.sh  rewritten rather than configured — the template's is
                 pg_restore/psql end to end with no seam. Replays the dump from
                 SQL into a scratch database and times it

Three guards in scripts/verify.d, because verify.sh would otherwise detect
nothing and exit 2: the build, the tracked-tree secret scan, and a check that
every document carries a valid Status, Governs and Review trigger.

Every guard was proven to fail before being trusted, per GUARDS.md rule 1:
healthcheck against a 200 that is not this app, secrets against the real
historical leak replayed out of 033bdf6, doc-headers against both a missing
Review trigger and the Status word "Historical", restore-check against a
truncated dump, an empty database and a raised row floor.

pre-commit is ADAPTED, not the template's. That one runs `npx tsc --noEmit` and
`npx vitest run`; this project has neither, so unchanged it would refuse every
commit. It runs the secret scan and `npm run build`. Hooks are not activated by
this commit — `git config core.hooksPath .githooks` is a separate, per-clone act.

package.json: adds `verify`, and corrects the version to 0.9.3. It said 0.8.3
while the last four commits said batch 0.9.0 through 0.9.3 — the second drift of
the phase-versioning rule, which is retired in the following commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:18:20 -05:00
null ac6f5d1f78 feat(seo): publish privacy policy, remove street address, prerender all routes (batch 0.9.3)
Client directive (Levi Halford, 2026-08-01) ahead of Google/Meta lead forms.

Privacy policy:
- Publish approved policy verbatim at /privacy-policy (src/data/privacyPolicy.js
  is the single source of truth; 292/292 source lines verified present)
- Privacy Policy link in the footer of every page
- Effective/Last Updated 2026-07-31, privacy@queuenorth.com as mailto

Remove St. Petersburg street address from every surface named in the brief:
footer, contact page, schema markup, SEO metadata, Google Maps links. Collapse
ProfessionalService + Organization schema into a single Organization with
areaServed: United States; drop geo coordinates, priceRange, openingHours.
Add the approved US-coverage sentence to About. No replacement address.

Crawler visibility (the site previously served 0 bytes of body HTML without JS):
- Prerender all 19 routes at build time via src/entry-server.jsx + scripts/prerender.js
- Hoist title/meta/canonical/JSON-LD into <head>; renderToString does not do this
  and react-helmet-async's context is empty under React 19
- Serve prerendered HTML; return a real 404 for unknown paths instead of 200
- Hydrate instead of discarding the prerendered markup

SEO/perf:
- Titles <=60 and descriptions <=160 chars across all pages
- Add BreadcrumbList to interior pages, WebSite to home
- Generate sitemap.xml from the route list with git-derived lastmod
- 301 duplicate URL forms (trailing slash, //, /index.html), preserving query
- Immutable caching for content-hashed assets; no-cache for HTML
- Split the 522 KB bundle into app/react-vendor/router/icons
- loading/decoding/fetchpriority + per-route hero preload; drop unused asset

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:45:52 -05:00
null 196389ddf3 fix(brand): update veteran-owned badge to certified mark logo (batch 0.9.2) 2026-06-14 16:17:42 -05:00
null d074e597b2 feat(zoho): WebToLead forwarding mode, veteran-owned certified badge, Docker/env/CI updates (batch 0.9.1) 2026-06-14 16:08:29 -05:00
null 05b27d216a feat: Dockerfile/docker-compose updates, server improvements, contact form with recaptcha, API integration (batch 0.9.0) 2026-06-14 15:37:26 -05:00
null 76cb558e8b chore: bump version to v0.8.3 2026-05-28 01:10:38 -05:00
null a8d9492a80 added 404 2026-05-28 00:41:24 -05:00
null 0f272fcf19 error and injection 2026-05-28 00:18:08 -05:00
null 3a61000c12 fix scroll 2026-05-27 23:40:09 -05:00
null e625a24b6e redirect to contact form 2026-05-27 22:33:54 -05:00